mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-20 06:04:22 -04:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aee12298a | ||
|
|
f5bba17f53 | ||
|
|
af57286c98 | ||
|
|
446452a97f | ||
|
|
868f54e425 | ||
|
|
c3bc3e6fbd | ||
|
|
1197be90d9 | ||
|
|
6f29018864 | ||
|
|
ac25283f04 | ||
|
|
2ecfe18f76 | ||
|
|
7515bb9898 | ||
|
|
4555fa266d | ||
|
|
60f40913a2 | ||
|
|
facaa18a3c | ||
|
|
8838cf28ed |
No files matched your search
@@ -64,6 +64,7 @@ jobs:
|
||||
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
|
||||
cp out/kwin/desktop-kde.kwin.txt out/
|
||||
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
|
||||
echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde
|
||||
@@ -74,6 +75,8 @@ jobs:
|
||||
out/desktop-kde.packages.txt
|
||||
out/desktop-kde.kwin.txt
|
||||
out/desktop-kde.sha256
|
||||
out/desktop-kde.i18n.txt
|
||||
out/desktop-kde-i18n-*.tar.zst
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
@@ -88,5 +91,6 @@ jobs:
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
|
||||
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
|
||||
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
|
||||
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
|
||||
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
@@ -29,7 +29,46 @@ env:
|
||||
PRELOAD_CACHE_VERSION: v6
|
||||
|
||||
jobs:
|
||||
xbox:
|
||||
name: Xbox ARM64 backend
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: actions/cache@v4
|
||||
id: xbox-cache
|
||||
with:
|
||||
path: |
|
||||
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
app/build/xbox/xodus-droiddeck-source.tar.xz
|
||||
app/build/xbox/runtime/*-source.tar.xz
|
||||
key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
|
||||
- name: Build Xbox backend
|
||||
if: steps.xbox-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
rustup toolchain install 1.99.0 --profile minimal
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
|
||||
bash tools/xbox/build.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xbox-arm64
|
||||
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
retention-days: 14
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xbox-corresponding-source
|
||||
path: |
|
||||
app/build/xbox/xodus-droiddeck-source.tar.xz
|
||||
app/build/xbox/runtime/*-source.tar.xz
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
build:
|
||||
needs: xbox
|
||||
name: Build APK
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -184,7 +223,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -192,7 +231,8 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
|
||||
-o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
|
||||
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
|
||||
D=app/src/main/assets/linuxfs
|
||||
mkdir -p "$D"
|
||||
@@ -463,6 +503,11 @@ jobs:
|
||||
restore-keys: |
|
||||
cxx-${{ env.NDK_VERSION }}-
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: xbox-arm64
|
||||
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
|
||||
- name: Build GOG Galaxy companion
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
||||
@@ -318,6 +318,7 @@ dependencies {
|
||||
implementation 'com.termux.termux-app:terminal-view:0.118.0'
|
||||
implementation 'androidx.camera:camera-camera2:1.4.2'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.4.2'
|
||||
implementation 'androidx.webkit:webkit:1.12.1'
|
||||
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
|
||||
exclude group: 'com.google.auto.value', module: 'auto-value'
|
||||
}
|
||||
|
||||
@@ -147,6 +147,10 @@
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity android:name=".stores.xbox.XboxLoginActivity" android:exported="false"
|
||||
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -11,23 +11,23 @@ add_subdirectory(adrenotools)
|
||||
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
|
||||
add_subdirectory(thirdparty/dxbc)
|
||||
|
||||
set(WINFG_UPSTREAM "v0.3.0")
|
||||
set(WINFG_CHAIN_SRC
|
||||
framegen/winfg/framegen.cpp
|
||||
framegen/winfg/framegen.hpp
|
||||
framegen/winfg/record_impl.inc
|
||||
framegen/winfg/config.hpp
|
||||
framegen/winfg/embedded_shaders.hpp
|
||||
framegen/winfg/winfg_engine.cpp
|
||||
set(DDFG_UPSTREAM "v0.3.0")
|
||||
set(DDFG_CHAIN_SRC
|
||||
framegen/ddfg/framegen.cpp
|
||||
framegen/ddfg/framegen.hpp
|
||||
framegen/ddfg/record_impl.inc
|
||||
framegen/ddfg/config.hpp
|
||||
framegen/ddfg/embedded_shaders.hpp
|
||||
framegen/ddfg/ddfg_engine.cpp
|
||||
)
|
||||
set(WINFG_HASH_INPUT "")
|
||||
foreach(f ${WINFG_CHAIN_SRC})
|
||||
set(DDFG_HASH_INPUT "")
|
||||
foreach(f ${DDFG_CHAIN_SRC})
|
||||
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h)
|
||||
string(APPEND WINFG_HASH_INPUT "${_h}")
|
||||
string(APPEND DDFG_HASH_INPUT "${_h}")
|
||||
endforeach()
|
||||
string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}")
|
||||
string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
|
||||
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
|
||||
string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}")
|
||||
string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH)
|
||||
message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})")
|
||||
|
||||
add_subdirectory(waylandcomp)
|
||||
add_subdirectory(questxr)
|
||||
|
||||
@@ -131,7 +131,7 @@ struct VkTable {
|
||||
#include <string>
|
||||
|
||||
namespace lsfg { class Engine; }
|
||||
namespace winfg { class Engine; }
|
||||
namespace ddfg { class Engine; }
|
||||
|
||||
static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2;
|
||||
|
||||
@@ -280,7 +280,7 @@ public:
|
||||
static constexpr int32_t kFgCaptureGame = -1;
|
||||
void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0);
|
||||
void setFrameGenEngine(int kind);
|
||||
void setWinFgTuning(int model, int perfPreset);
|
||||
void setDdFgTuning(int model, int perfPreset);
|
||||
|
||||
private:
|
||||
struct WinTex {
|
||||
@@ -468,12 +468,12 @@ private:
|
||||
uint64_t fgSourceFrames_ = 0;
|
||||
std::string lsfgCachePath_;
|
||||
bool lsfgEngineTried_ = false;
|
||||
std::unique_ptr<winfg::Engine> winfgEngine_;
|
||||
bool winfgEngineTried_ = false;
|
||||
std::unique_ptr<ddfg::Engine> ddfgEngine_;
|
||||
bool ddfgEngineTried_ = false;
|
||||
std::atomic<int> fgEngineKind_{0};
|
||||
std::atomic<int> fgModel_{4};
|
||||
std::atomic<int> fgPerfPreset_{1};
|
||||
bool ensureWinFgEngine();
|
||||
bool ensureDdFgEngine();
|
||||
bool fgCapsOk() const;
|
||||
|
||||
uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; }
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+36
-36
@@ -6,7 +6,7 @@
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
struct Config {
|
||||
bool enabled = false;
|
||||
@@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) {
|
||||
}
|
||||
|
||||
static inline std::string conf_path() {
|
||||
if (const char* p = std::getenv("WIN_FG_CONF")) return p;
|
||||
if (const char* p = std::getenv("DD_FG_CONF")) return p;
|
||||
const char* home = std::getenv("HOME");
|
||||
return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string();
|
||||
return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string();
|
||||
}
|
||||
|
||||
static inline std::string capture_root(const Config& c) {
|
||||
if (!c.capDir.empty()) return c.capDir;
|
||||
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture";
|
||||
return std::string("/tmp/winfg-capture");
|
||||
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture";
|
||||
return std::string("/tmp/ddfg-capture");
|
||||
}
|
||||
|
||||
static inline Config load_config() {
|
||||
Config c;
|
||||
c.enabled = envi("WIN_FG_ENABLE", 0) != 0;
|
||||
if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
|
||||
if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
|
||||
c.model = envi("WIN_FG_MODEL", c.model);
|
||||
c.multiplier = envi("WIN_FG_MULT", c.multiplier);
|
||||
c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages);
|
||||
c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset);
|
||||
if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
|
||||
if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
|
||||
c.frIters = envi("WIN_FG_FR_ITERS", c.frIters);
|
||||
c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda);
|
||||
c.frDt = envf("WIN_FG_FR_DT", c.frDt);
|
||||
c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge);
|
||||
c.frEps = envf("WIN_FG_FR_EPS", c.frEps);
|
||||
c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale);
|
||||
c.beta = envf("WIN_FG_BETA", c.beta);
|
||||
c.lambda = envf("WIN_FG_LAMBDA", c.lambda);
|
||||
c.epsilon = envf("WIN_FG_EPSILON", c.epsilon);
|
||||
c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale);
|
||||
if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
|
||||
c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW);
|
||||
c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH);
|
||||
if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
|
||||
c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches);
|
||||
c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize);
|
||||
c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion);
|
||||
c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB);
|
||||
if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd;
|
||||
if (const char* h = std::getenv("WIN_FG_HUD_RECT")) {
|
||||
c.enabled = envi("DD_FG_ENABLE", 0) != 0;
|
||||
if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
|
||||
if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
|
||||
c.model = envi("DD_FG_MODEL", c.model);
|
||||
c.multiplier = envi("DD_FG_MULT", c.multiplier);
|
||||
c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages);
|
||||
c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset);
|
||||
if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
|
||||
if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
|
||||
c.frIters = envi("DD_FG_FR_ITERS", c.frIters);
|
||||
c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda);
|
||||
c.frDt = envf("DD_FG_FR_DT", c.frDt);
|
||||
c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge);
|
||||
c.frEps = envf("DD_FG_FR_EPS", c.frEps);
|
||||
c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale);
|
||||
c.beta = envf("DD_FG_BETA", c.beta);
|
||||
c.lambda = envf("DD_FG_LAMBDA", c.lambda);
|
||||
c.epsilon = envf("DD_FG_EPSILON", c.epsilon);
|
||||
c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale);
|
||||
if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
|
||||
c.captureW = envi("DD_FG_CAPTURE_W", c.captureW);
|
||||
c.captureH = envi("DD_FG_CAPTURE_H", c.captureH);
|
||||
if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
|
||||
c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches);
|
||||
c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize);
|
||||
c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion);
|
||||
c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB);
|
||||
if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd;
|
||||
if (const char* h = std::getenv("DD_FG_HUD_RECT")) {
|
||||
float r[4] = {0,0,0,0}; int n = 0;
|
||||
std::string s = h; size_t start = 0;
|
||||
for (; n < 4; ++n) {
|
||||
@@ -266,8 +266,8 @@ static inline Config load_config() {
|
||||
c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3];
|
||||
}
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml");
|
||||
if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p);
|
||||
if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml");
|
||||
if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p);
|
||||
c.sanitize();
|
||||
return c;
|
||||
}
|
||||
+13
-10
@@ -1,10 +1,10 @@
|
||||
#include "winfg_engine.h"
|
||||
#include "winfg_vkd.h"
|
||||
#include "ddfg_engine.h"
|
||||
#include "ddfg_vkd.h"
|
||||
#include "log.hpp"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
namespace {
|
||||
|
||||
float alphaFor(uint32_t k, uint32_t n) {
|
||||
@@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); }
|
||||
|
||||
bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev,
|
||||
uint32_t queueFamily, VkQueue queue) {
|
||||
if (!winfgVkdInit(table, dd_, id_)) {
|
||||
WFG_LOGE("native: dispatch incomplete; frame generation unavailable");
|
||||
if (!ddfgVkdInit(table, dd_, id_)) {
|
||||
DDFG_LOGE("native: dispatch incomplete; frame generation unavailable");
|
||||
return false;
|
||||
}
|
||||
phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue;
|
||||
id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_);
|
||||
if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) {
|
||||
WFG_LOGE("native: FrameGen::init failed");
|
||||
DDFG_LOGE("native: FrameGen::init failed");
|
||||
unavailable_ = true;
|
||||
return false;
|
||||
}
|
||||
WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
|
||||
DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
|
||||
|
||||
if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) {
|
||||
WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
|
||||
DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
|
||||
unavailable_ = true;
|
||||
return false;
|
||||
}
|
||||
@@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
}
|
||||
builtW_ = w; builtH_ = h; builtFmt_ = fmt;
|
||||
history_ = 0; curr_ = 0; frames_ = 0;
|
||||
WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
|
||||
DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
|
||||
w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale);
|
||||
return true;
|
||||
}
|
||||
|
||||
static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot");
|
||||
|
||||
uint32_t Engine::plan(uint32_t capacity) const {
|
||||
if (!valid() || history_ < 2) return 0;
|
||||
const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0);
|
||||
@@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou
|
||||
dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite);
|
||||
|
||||
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_);
|
||||
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_,
|
||||
generation, generation > 0);
|
||||
if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots;
|
||||
|
||||
(void)w; (void)h;
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
struct VkTable;
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
constexpr uint32_t kMaxGenerations = 3;
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
#include "winfg_vkd.h"
|
||||
#include "ddfg_vkd.h"
|
||||
#include "../VulkanRendererContext.h"
|
||||
#include <android/log.h>
|
||||
|
||||
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) {
|
||||
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) {
|
||||
bool ok = true;
|
||||
#define COPY(dst, fn) \
|
||||
do { \
|
||||
dst.fn = table.fn; \
|
||||
if (!dst.fn) { \
|
||||
__android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \
|
||||
__android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \
|
||||
"entry point vk" #fn " did not resolve"); \
|
||||
ok = false; \
|
||||
} \
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
#include "vk_dispatch.hpp"
|
||||
struct VkTable;
|
||||
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id);
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
namespace winfg { namespace embedded {
|
||||
namespace ddfg { namespace embedded {
|
||||
static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x0Line truncated
|
||||
static const size_t OF3_LUMA_LEN = 481;
|
||||
static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x0005006Line truncated
|
||||
@@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b
|
||||
static const size_t OF3_GM_PREWARP_LEN = 706;
|
||||
static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,Line truncated
|
||||
static const size_t OF3_FLOWREG_LEN = 1936;
|
||||
static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0xLine truncated
|
||||
static const size_t WFG_SYNTH_LEN = 3082;
|
||||
static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0Line truncated
|
||||
static const size_t DDFG_SYNTH_LEN = 3082;
|
||||
struct Blob { const uint32_t* code; size_t words; };
|
||||
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT };
|
||||
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT };
|
||||
static inline Blob blob(Shader s) {
|
||||
switch (s) {
|
||||
case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN };
|
||||
@@ -35,7 +35,7 @@ static inline Blob blob(Shader s) {
|
||||
case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN };
|
||||
case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN };
|
||||
case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN };
|
||||
case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN };
|
||||
case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN };
|
||||
default: return { nullptr, 0 };
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -4,9 +4,9 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0)
|
||||
#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0)
|
||||
|
||||
uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const {
|
||||
for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i)
|
||||
@@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id,
|
||||
if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false;
|
||||
if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false;
|
||||
if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false;
|
||||
if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false;
|
||||
if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
|
||||
WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
|
||||
if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false;
|
||||
if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
|
||||
DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
|
||||
queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters);
|
||||
return true;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) {
|
||||
if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
|
||||
if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
|
||||
ready_ = true;
|
||||
WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
|
||||
DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
|
||||
extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset);
|
||||
return true;
|
||||
}
|
||||
+4
-2
@@ -3,7 +3,7 @@
|
||||
#include "config.hpp"
|
||||
#include <vector>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
class FrameGen {
|
||||
public:
|
||||
@@ -12,7 +12,8 @@ public:
|
||||
void configure(const Config& c);
|
||||
bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false);
|
||||
void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
|
||||
VkImageView outView, float alpha, uint32_t gmSlot = 0);
|
||||
VkImageView outView, float alpha, uint32_t gmSlot = 0,
|
||||
uint32_t synthSlot = 0, bool reuseFlow = false);
|
||||
void destroy();
|
||||
bool valid() const { return device_ != VK_NULL_HANDLE && ready_; }
|
||||
const Config& config() const { return cfg_; }
|
||||
@@ -25,6 +26,7 @@ public:
|
||||
|
||||
|
||||
static constexpr int kGmSlots = 3;
|
||||
static constexpr int kSynthSlots = 3;
|
||||
static constexpr int kGmLevel = 3;
|
||||
static constexpr int kGmThreads = 512;
|
||||
static constexpr int kGmStride = 32;
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#if defined(__ANDROID__)
|
||||
#include <android/log.h>
|
||||
#define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__)
|
||||
#define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__)
|
||||
#else
|
||||
#include <cstdio>
|
||||
#define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#endif
|
||||
|
||||
#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0)
|
||||
+159
-142
@@ -1,31 +1,34 @@
|
||||
|
||||
namespace {
|
||||
struct ScratchState {
|
||||
VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr;
|
||||
VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{};
|
||||
VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{};
|
||||
void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{};
|
||||
std::vector<VkBuffer> flowUbo; std::vector<VkDeviceMemory> flowUboMem; std::vector<void*> flowUboPtr;
|
||||
VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr;
|
||||
VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr;
|
||||
VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{};
|
||||
VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{};
|
||||
void* gmUboPtr[winfg::FrameGen::kGmSlots]{};
|
||||
VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{};
|
||||
VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{};
|
||||
void* gmUboPtr[ddfg::FrameGen::kGmSlots]{};
|
||||
VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE;
|
||||
std::vector<VkDescriptorSet> sDownA, sDownB, sFlow;
|
||||
VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{};
|
||||
VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{};
|
||||
|
||||
|
||||
|
||||
VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE;
|
||||
VkDescriptorSet sSynth = VK_NULL_HANDLE;
|
||||
VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{};
|
||||
std::vector<VkDescriptorSet> sGmPrewarp[winfg::FrameGen::kGmSlots];
|
||||
VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{};
|
||||
VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{};
|
||||
std::vector<VkDescriptorSet> sGmPrewarp[ddfg::FrameGen::kGmSlots];
|
||||
VkExtent2D builtFor{0,0};
|
||||
bool built = false;
|
||||
bool flowInited = false;
|
||||
bool flowReady = false;
|
||||
};
|
||||
ScratchState g_scratch;
|
||||
}
|
||||
|
||||
static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
|
||||
static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev,
|
||||
const VkPhysicalDeviceMemoryProperties& mp,
|
||||
VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) {
|
||||
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||
@@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
|
||||
return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS;
|
||||
}
|
||||
|
||||
static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev,
|
||||
static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev,
|
||||
VkDescriptorPool pool, VkDescriptorSetLayout layout) {
|
||||
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
||||
ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout;
|
||||
VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s;
|
||||
}
|
||||
|
||||
static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) {
|
||||
VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout;
|
||||
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = ⅈ
|
||||
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
|
||||
}
|
||||
static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
VkBuffer buf, VkDeviceSize range) {
|
||||
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
|
||||
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
@@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet
|
||||
w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi;
|
||||
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
|
||||
}
|
||||
static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
|
||||
VkBuffer buf, VkDeviceSize range) {
|
||||
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
|
||||
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
@@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3])
|
||||
out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0;
|
||||
}
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool,
|
||||
@@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP
|
||||
b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr;
|
||||
};
|
||||
auto& S = g_scratch;
|
||||
freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr);
|
||||
for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
|
||||
freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr);
|
||||
freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
|
||||
for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]);
|
||||
@@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) {
|
||||
int want = flowFinestForPreset(cfg_.perfPreset);
|
||||
const int maxFinest = kLevels - 2;
|
||||
if (want > maxFinest) {
|
||||
WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
|
||||
DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
|
||||
"(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest);
|
||||
want = maxFinest;
|
||||
}
|
||||
@@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) {
|
||||
const int prevFinest = flowFinest_;
|
||||
flowFinest_ = want;
|
||||
const bool live = (flowFinest_ != prevFinest) && ready_;
|
||||
WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
|
||||
DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
|
||||
cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0);
|
||||
|
||||
if (!live) return;
|
||||
@@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) {
|
||||
dd_->DeviceWaitIdle(device_);
|
||||
destroyScratch(dd_, device_, descPool_);
|
||||
if (!onResize(extent_, VK_FORMAT_UNDEFINED, true))
|
||||
WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
|
||||
DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
|
||||
extent_.width, extent_.height);
|
||||
for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0;
|
||||
for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; }
|
||||
gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f;
|
||||
WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
|
||||
DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
|
||||
}
|
||||
|
||||
bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
|
||||
@@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
|
||||
}
|
||||
|
||||
void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
|
||||
VkImageView outView, float alpha, uint32_t gmSlot) {
|
||||
VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) {
|
||||
if (!valid()) return;
|
||||
if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0;
|
||||
if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0;
|
||||
const bool m4 = (cfg_.model == 4);
|
||||
const Pipe& flowP = m4 ? pFlowM4_ : pFlow_;
|
||||
const Pipe& expandP = m4 ? pExpandM4_ : pExpand_;
|
||||
@@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
|
||||
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
|
||||
}
|
||||
|
||||
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
|
||||
if (g_scratch.gmUboPtr[gmSlot]) {
|
||||
GMUBO g{};
|
||||
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
|
||||
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
|
||||
if (gmEngaged) {
|
||||
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
|
||||
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
|
||||
// Every generation of one real frame is recorded into the same command buffer, so the flow
|
||||
// between prev and curr is computed once and each generation gets its own synth UBO and set.
|
||||
if (reuseFlow && !g_scratch.flowReady) return;
|
||||
if (!reuseFlow) {
|
||||
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
|
||||
if (g_scratch.gmUboPtr[gmSlot]) {
|
||||
GMUBO g{};
|
||||
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
|
||||
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
|
||||
if (gmEngaged) {
|
||||
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
|
||||
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
|
||||
}
|
||||
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
|
||||
}
|
||||
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
|
||||
}
|
||||
{
|
||||
static int lastEng = -1;
|
||||
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
|
||||
WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
|
||||
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
|
||||
lastEng = gmEngaged ? 1 : 0;
|
||||
{
|
||||
static int lastEng = -1;
|
||||
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
|
||||
DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
|
||||
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
|
||||
lastEng = gmEngaged ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
}
|
||||
|
||||
SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0,
|
||||
cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1};
|
||||
if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su));
|
||||
if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su));
|
||||
|
||||
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
|
||||
const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot];
|
||||
wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||||
wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
|
||||
|
||||
auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) {
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline);
|
||||
@@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
|
||||
};
|
||||
auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); };
|
||||
|
||||
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
{
|
||||
VkClearColorValue zero{};
|
||||
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
if (!reuseFlow) {
|
||||
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
|
||||
{
|
||||
VkClearColorValue zero{};
|
||||
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
for (int l = flowFinest_; l < kLevels; ++l) {
|
||||
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
b.image = flowLvl_[l].image; b.subresourceRange = rng;
|
||||
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
|
||||
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
}
|
||||
}
|
||||
|
||||
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
|
||||
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
|
||||
bar(pyrA_[0]); bar(pyrB_[0]);
|
||||
for (int l = 1; l < kLevels; ++l) {
|
||||
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
|
||||
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
|
||||
bar(pyrA_[l]); bar(pyrB_[l]);
|
||||
}
|
||||
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
}
|
||||
for (int l = flowFinest_; l < kLevels; ++l) {
|
||||
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
b.image = flowLvl_[l].image; b.subresourceRange = rng;
|
||||
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
|
||||
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
|
||||
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
|
||||
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
|
||||
bar(pyrAs_[l]);
|
||||
}
|
||||
for (int l = kLevels - 1; l >= flowFinest_; --l) {
|
||||
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
|
||||
bar(flowLvl_[l]);
|
||||
}
|
||||
}
|
||||
|
||||
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
|
||||
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
|
||||
bar(pyrA_[0]); bar(pyrB_[0]);
|
||||
for (int l = 1; l < kLevels; ++l) {
|
||||
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
|
||||
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
|
||||
bar(pyrA_[l]); bar(pyrB_[l]);
|
||||
}
|
||||
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
}
|
||||
for (int l = flowFinest_; l < kLevels; ++l) {
|
||||
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
|
||||
bar(pyrAs_[l]);
|
||||
}
|
||||
for (int l = kLevels - 1; l >= flowFinest_; --l) {
|
||||
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
|
||||
bar(flowLvl_[l]);
|
||||
}
|
||||
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
|
||||
if (frEngaged) {
|
||||
const int F = flowFinest_;
|
||||
const VkExtent2D fe = levelExtent(extent_, F);
|
||||
if (g_scratch.flowRegUboPtr) {
|
||||
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
|
||||
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
|
||||
}
|
||||
FrameGen::Img* last = nullptr;
|
||||
for (int it = 0; it < cfg_.frIters; ++it) {
|
||||
VkDescriptorSet set; FrameGen::Img* outImg;
|
||||
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
|
||||
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
|
||||
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
|
||||
bar(*outImg);
|
||||
last = outImg;
|
||||
}
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
VkImageCopy region{};
|
||||
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.extent = {fe.width, fe.height, 1};
|
||||
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
|
||||
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion);
|
||||
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
0, 1, &mb2, 0, nullptr, 0, nullptr);
|
||||
}
|
||||
{
|
||||
static int lastFr = -1;
|
||||
int fe = frEngaged ? 1 : 0;
|
||||
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
|
||||
DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
|
||||
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
|
||||
lastFr = fe;
|
||||
}
|
||||
}
|
||||
|
||||
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
|
||||
if (frEngaged) {
|
||||
const int F = flowFinest_;
|
||||
const VkExtent2D fe = levelExtent(extent_, F);
|
||||
if (g_scratch.flowRegUboPtr) {
|
||||
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
|
||||
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
|
||||
}
|
||||
FrameGen::Img* last = nullptr;
|
||||
for (int it = 0; it < cfg_.frIters; ++it) {
|
||||
VkDescriptorSet set; FrameGen::Img* outImg;
|
||||
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
|
||||
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
|
||||
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
|
||||
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
|
||||
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
|
||||
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
|
||||
bar(*outImg);
|
||||
last = outImg;
|
||||
}
|
||||
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
|
||||
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
VkImageCopy region{};
|
||||
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.extent = {fe.width, fe.height, 1};
|
||||
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
|
||||
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion);
|
||||
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
|
||||
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
|
||||
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
0, 1, &mb2, 0, nullptr, 0, nullptr);
|
||||
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
|
||||
bar(flowExpA_); bar(flowExpB_);
|
||||
g_scratch.flowReady = true;
|
||||
}
|
||||
{
|
||||
static int lastFr = -1;
|
||||
int fe = frEngaged ? 1 : 0;
|
||||
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
|
||||
WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
|
||||
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
|
||||
lastFr = fe;
|
||||
}
|
||||
}
|
||||
|
||||
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
|
||||
bar(flowExpA_); bar(flowExpB_);
|
||||
disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0));
|
||||
disp(pSynth_, synthSet, levelExtent(extent_, 0));
|
||||
}
|
||||
|
||||
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
@@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
S = ScratchState{};
|
||||
const auto GEN = VK_IMAGE_LAYOUT_GENERAL;
|
||||
|
||||
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr);
|
||||
for (int s = 0; s < FrameGen::kSynthSlots; ++s)
|
||||
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
|
||||
makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr);
|
||||
if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); }
|
||||
makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
|
||||
@@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
|
||||
wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN);
|
||||
}
|
||||
|
||||
S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout);
|
||||
wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO));
|
||||
wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
|
||||
wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
|
||||
for (int s = 0; s < FrameGen::kSynthSlots; ++s) {
|
||||
S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout);
|
||||
wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO));
|
||||
wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
|
||||
wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
|
||||
}
|
||||
|
||||
S.builtFor = ext; S.built = true;
|
||||
return true;
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
#include <vulkan/vk_layer.h>
|
||||
#include <cstring>
|
||||
|
||||
namespace winfg {
|
||||
namespace ddfg {
|
||||
|
||||
static inline void* dispatch_key(void* handle) { return *reinterpret_cast<void**>(handle); }
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#pragma once
|
||||
#if defined(__ANDROID__)
|
||||
#include <android/log.h>
|
||||
#define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__)
|
||||
#define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__)
|
||||
#else
|
||||
#include <cstdio>
|
||||
#define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#endif
|
||||
|
||||
#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0)
|
||||
@@ -1,4 +0,0 @@
|
||||
#pragma once
|
||||
#include "vk_dispatch.hpp"
|
||||
struct VkTable;
|
||||
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id);
|
||||
@@ -43,10 +43,10 @@ target_include_directories(droiddeckwayland PRIVATE
|
||||
)
|
||||
|
||||
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
|
||||
set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
|
||||
set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg)
|
||||
if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
|
||||
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
|
||||
AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp)
|
||||
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)")
|
||||
target_sources(droiddeckwayland PRIVATE
|
||||
src/framegen_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
|
||||
@@ -66,9 +66,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp
|
||||
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp
|
||||
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp
|
||||
)
|
||||
target_include_directories(droiddeckwayland PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/..
|
||||
@@ -77,8 +77,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
target_compile_options(droiddeckwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
|
||||
target_compile_definitions(droiddeckwayland PRIVATE
|
||||
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
|
||||
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
|
||||
DDFG_UPSTREAM="${DDFG_UPSTREAM}"
|
||||
DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}"
|
||||
)
|
||||
target_link_libraries(droiddeckwayland dxbc atomic)
|
||||
else()
|
||||
|
||||
@@ -86,7 +86,7 @@ static int64_t now_ns(void) {
|
||||
|
||||
|
||||
void vkp_framegen_set_engine(int kind) {
|
||||
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG;
|
||||
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG;
|
||||
atomic_store(&g_kind, kind);
|
||||
atomic_store(&g_failed_kind, -1);
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
@@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) {
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_winfg_tuning(int model, int perf_preset) {
|
||||
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) {
|
||||
atomic_store(&g_model, model == 3 ? 3 : 4);
|
||||
atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset);
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
@@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f
|
||||
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
|
||||
fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled);
|
||||
g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier;
|
||||
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s",
|
||||
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s",
|
||||
fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(),
|
||||
fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable");
|
||||
fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable");
|
||||
}
|
||||
|
||||
void vkp_framegen_device_lost(void) { g_dev_lost = 1; }
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
#endif
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
|
||||
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 };
|
||||
#define VKP_FG_MAX_GENERATIONS 3
|
||||
|
||||
void vkp_framegen_set_engine(int kind);
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path);
|
||||
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
|
||||
void vkp_framegen_set_winfg_tuning(int model, int perf_preset);
|
||||
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset);
|
||||
int vkp_framegen_problem(void);
|
||||
const char *vkp_framegen_caps_reason(void);
|
||||
void vkp_framegen_stats(float out[6]);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "lsfg/lsfg_engine.h"
|
||||
#include "lsfg/lsfg_probe.h"
|
||||
#include "lsfg/lsfg_vkd.h"
|
||||
#include "winfg/winfg_engine.h"
|
||||
#include "ddfg/ddfg_engine.h"
|
||||
|
||||
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
@@ -30,7 +30,7 @@ uint32_t g_qfam = 0;
|
||||
bool g_probed = false, g_ready = false;
|
||||
|
||||
std::unique_ptr<lsfg::Engine> g_lsfg;
|
||||
std::unique_ptr<winfg::Engine> g_winfg;
|
||||
std::unique_ptr<ddfg::Engine> g_ddfg;
|
||||
int g_kind = -1;
|
||||
char g_build[96] = "";
|
||||
|
||||
@@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) {
|
||||
fge_stop();
|
||||
if (!g_ready) return -1;
|
||||
if (kind == 1) {
|
||||
auto e = std::make_unique<winfg::Engine>();
|
||||
auto e = std::make_unique<ddfg::Engine>();
|
||||
if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) {
|
||||
ELOGE("winfg-native: engine init failed");
|
||||
ELOGE("ddfg-native: engine init failed");
|
||||
return -1;
|
||||
}
|
||||
g_winfg = std::move(e);
|
||||
g_ddfg = std::move(e);
|
||||
g_kind = 1;
|
||||
#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH)
|
||||
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH);
|
||||
#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH)
|
||||
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH);
|
||||
#else
|
||||
std::snprintf(g_build, sizeof(g_build), "embedded chain");
|
||||
#endif
|
||||
@@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) {
|
||||
}
|
||||
|
||||
void fge_stop(void) {
|
||||
if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
|
||||
if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
|
||||
g_lsfg.reset();
|
||||
g_winfg.reset();
|
||||
g_ddfg.reset();
|
||||
g_kind = -1;
|
||||
}
|
||||
|
||||
int fge_unavailable(void) {
|
||||
if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0;
|
||||
if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0;
|
||||
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
|
||||
}
|
||||
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
|
||||
int model, int perf_preset) {
|
||||
if (multiplier < 2) multiplier = 2;
|
||||
if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale);
|
||||
if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale);
|
||||
else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz);
|
||||
}
|
||||
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0;
|
||||
if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0;
|
||||
if (!g_lsfg) return 0;
|
||||
g_lsfg->setGuestExtent(w, h);
|
||||
return g_lsfg->prepare(w, h, fmt) ? 1 : 0;
|
||||
}
|
||||
|
||||
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
|
||||
if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0;
|
||||
if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0;
|
||||
if (!g_lsfg) return 0;
|
||||
g_lsfg->setPresentedRate(presented_rate);
|
||||
return g_lsfg->plan(capacity, source_frames);
|
||||
}
|
||||
|
||||
void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) {
|
||||
if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; }
|
||||
if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; }
|
||||
if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens);
|
||||
}
|
||||
|
||||
void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view,
|
||||
uint32_t w, uint32_t h) {
|
||||
if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; }
|
||||
if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; }
|
||||
if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h);
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
|
||||
}
|
||||
}
|
||||
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
|
||||
const char *fge_build_info(void) { return g_build; }
|
||||
|
||||
}
|
||||
@@ -34,5 +34,5 @@ void fge_forget_targets(void) {}
|
||||
void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
|
||||
*accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f;
|
||||
}
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
|
||||
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
|
||||
const char *fge_build_info(void) { return "stub"; }
|
||||
@@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz,
|
||||
jint model, jint perfPreset) {
|
||||
(void)env; (void)clazz;
|
||||
vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset);
|
||||
vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
|
||||
@@ -20,6 +20,10 @@ class App : Application() {
|
||||
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
|
||||
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
|
||||
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
|
||||
if (process == "$packageName:xboxlogin") {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
|
||||
return
|
||||
}
|
||||
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
@@ -35,5 +39,7 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadMouse
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.input.DirectTouchHold
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
@@ -188,6 +189,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
private var watching = true
|
||||
private lateinit var touchpad: TouchpadGestures
|
||||
private lateinit var directTouchHold: DirectTouchHold
|
||||
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
|
||||
private lateinit var scrollTouch: ScrollTouchGestures
|
||||
private lateinit var padMouse: PadMouse
|
||||
@@ -483,6 +485,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
|
||||
keyboard = KeyboardHost(this).also { root.addView(it) }
|
||||
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
|
||||
directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
}
|
||||
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
|
||||
padMouse = PadMouse(padMouseListener)
|
||||
bridge.setTouchRoute(object : PadBridge.TouchRoute {
|
||||
@@ -1296,6 +1301,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val nextTouchMode = SessionPrefs.touchMode(this)
|
||||
if (touchMode != nextTouchMode) {
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
if (CompositorHost.isStarted) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
@@ -1555,7 +1561,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun followChoice(): Boolean {
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = displayMode()
|
||||
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
|
||||
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
|
||||
}
|
||||
@@ -1594,8 +1600,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
|
||||
}
|
||||
|
||||
private fun displayMode(): String = SessionPrefs.displayMode(this,
|
||||
intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM))
|
||||
|
||||
private fun outputSize(): Pair<Int, Int> {
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = displayMode()
|
||||
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
|
||||
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
@@ -2286,6 +2295,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
if (next == routedTouchMode) return
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
@@ -2353,6 +2363,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
|
||||
return true
|
||||
}
|
||||
if (directTouchHold.onTouch(event)) return true
|
||||
fun sendTouch(action: Int, index: Int) {
|
||||
val x = event.getX(index).coerceIn(rect.left, rect.right)
|
||||
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
|
||||
@@ -2502,6 +2513,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
controls.visibility = View.GONE
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
applyTouchRouting()
|
||||
return
|
||||
}
|
||||
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
|
||||
@@ -2514,6 +2526,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (steam == null) {
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
applyTouchRouting()
|
||||
}
|
||||
controls.setButtonsOnly(buttonsOnly)
|
||||
val show = when (forced) {
|
||||
@@ -2576,6 +2589,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
releasePads()
|
||||
touchpad.cancel()
|
||||
scrollTouch.cancel()
|
||||
directTouchHold.cancel()
|
||||
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
|
||||
keyboard?.hide()
|
||||
sessionClipboard.stop()
|
||||
@@ -2702,6 +2716,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onPause() {
|
||||
if (runtimeRemovalBlocked) { super.onPause(); return }
|
||||
sessionClipboard.stop()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
@@ -2951,7 +2966,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun glassesOutputSize(): Pair<Int, Int>? {
|
||||
if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null
|
||||
val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = displayMode()
|
||||
val display = com.droiddeck.launcher.session.SessionDisplay
|
||||
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
|
||||
val size = display.resolveChoice(panel, choice)
|
||||
|
||||
@@ -200,6 +200,7 @@ object DeviceReport {
|
||||
h("Drivers")
|
||||
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
|
||||
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
|
||||
if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
|
||||
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
|
||||
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
|
||||
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
|
||||
|
||||
@@ -243,6 +243,8 @@ object AddedGames {
|
||||
|
||||
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
|
||||
val app = context.applicationContext
|
||||
// Binds are mounted at session start, so a new one reaches the session from the next start.
|
||||
if (guestPath(app, dir) == null) bindFolder(app, dir)
|
||||
val removed = SessionPrefs.removedAddedGames(app).toSet()
|
||||
val listed = listedFolders(app, removed)
|
||||
var already = 0
|
||||
@@ -266,6 +268,20 @@ object AddedGames {
|
||||
return FolderResult(added, already)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
internal fun bindFolder(context: Context, dir: File) {
|
||||
if (!dir.isDirectory || !dir.canRead()) return
|
||||
val binds = SessionPrefs.gameFolderBinds(context)
|
||||
if (binds.any { it.first == dir.path }) return
|
||||
val taken = binds.map { it.second }.toSet()
|
||||
val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest
|
||||
var guest = base
|
||||
var n = 2
|
||||
while (guest in taken) guest = "$base-${n++}"
|
||||
SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest))
|
||||
Log.i(TAG, "games: bound $dir at $guest")
|
||||
}
|
||||
|
||||
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
GamesFolderMigration.run(context)
|
||||
|
||||
@@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
object FrameGen {
|
||||
const val ENGINE_OFF = "off"
|
||||
const val ENGINE_WINFG = "winfg"
|
||||
const val ENGINE_DDFG = "ddfg"
|
||||
private const val LEGACY_ENGINE_WINFG = "winfg"
|
||||
const val ENGINE_LSFG = "lsfg"
|
||||
|
||||
private const val WINFG_PERF_PRESET = 2
|
||||
private const val WINFG_MODEL = 4
|
||||
private const val FLOW_SCALE_WINFG = 0.60f
|
||||
private const val DDFG_PERF_PRESET = 2
|
||||
private const val DDFG_MODEL = 4
|
||||
private const val FLOW_SCALE_DDFG = 0.60f
|
||||
private const val FLOW_SCALE_LSFG = 0.80f
|
||||
|
||||
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
|
||||
@@ -29,7 +30,7 @@ object FrameGen {
|
||||
|
||||
fun mode(context: Context): Mode {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
|
||||
val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: ENGINE_OFF }
|
||||
val target = prefs.getInt("target", 0)
|
||||
if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target)
|
||||
return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4))
|
||||
@@ -43,7 +44,7 @@ object FrameGen {
|
||||
fun label(context: Context): String = label(context, mode(context))
|
||||
|
||||
fun label(context: Context, mode: Mode): String = when {
|
||||
mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier)
|
||||
mode.engine == ENGINE_DDFG -> context.getString(R.string.frame_gen_ddfg, mode.multiplier)
|
||||
mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target)
|
||||
mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier)
|
||||
else -> context.getString(R.string.frame_gen_off)
|
||||
@@ -68,10 +69,10 @@ object FrameGen {
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target)
|
||||
}
|
||||
ENGINE_WINFG -> {
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG)
|
||||
WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz)
|
||||
ENGINE_DDFG -> {
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG)
|
||||
WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
|
||||
}
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
|
||||
@@ -11,8 +11,10 @@ object TpUbwcHint {
|
||||
|
||||
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
|
||||
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
|
||||
internal fun features(model: Int, systemDriverVersion: String?): String? {
|
||||
// Samsung's One UI firmware sets it whatever blob version it reports.
|
||||
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
|
||||
if (model != 740) return null
|
||||
if (samsung) return "enable_tp_ubwc_flag_hint=1"
|
||||
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
|
||||
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
|
||||
}
|
||||
@@ -20,11 +22,12 @@ object TpUbwcHint {
|
||||
@Synchronized
|
||||
fun forThisDevice(): String? {
|
||||
if (resolved) return cached
|
||||
val model = GpuInfo.detect().model
|
||||
val gpu = GpuInfo.detect()
|
||||
val model = gpu.model
|
||||
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
|
||||
cached = features(model, version)
|
||||
cached = features(model, version, gpu.oneUi8Gen2)
|
||||
resolved = true
|
||||
Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
|
||||
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
|
||||
return cached
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.abs
|
||||
|
||||
class DirectTouchHold(
|
||||
private val slopPx: Float,
|
||||
private val listener: PointerGestures.Listener,
|
||||
private val cancelTouch: () -> Unit,
|
||||
) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var x = 0f
|
||||
private var y = 0f
|
||||
private var consumed = false
|
||||
private var pressed = false
|
||||
private val release = Runnable {
|
||||
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
|
||||
pressed = false
|
||||
}
|
||||
private val hold = Runnable {
|
||||
cancelTouch()
|
||||
consumed = true
|
||||
listener.onLongPress()
|
||||
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
|
||||
pressed = true
|
||||
handler.postDelayed(release, 50L)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(release)
|
||||
release.run()
|
||||
consumed = false
|
||||
}
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
cancel()
|
||||
x = event.x; y = event.y
|
||||
handler.postDelayed(hold, 500L)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
|
||||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
|
||||
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
|
||||
MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
|
||||
MotionEvent.ACTION_CANCEL -> cancel()
|
||||
}
|
||||
return consumed
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import java.io.File
|
||||
|
||||
object GuestCommand {
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
@@ -19,13 +19,14 @@ object GuestCommand {
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
} finally {
|
||||
log?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
|
||||
fastPath: Boolean, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
@@ -33,8 +34,10 @@ object GuestCommand {
|
||||
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
|
||||
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
|
||||
if (linkDir != null) cmd.add(1, "--link2symlink")
|
||||
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
|
||||
.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
|
||||
binds.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
// Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
|
||||
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
|
||||
cmd += listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
|
||||
@@ -42,6 +45,7 @@ object GuestCommand {
|
||||
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
|
||||
cmd += argv
|
||||
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
|
||||
builder.environment().apply {
|
||||
@@ -49,6 +53,7 @@ object GuestCommand {
|
||||
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
|
||||
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
|
||||
@@ -71,6 +71,9 @@ object UserApps {
|
||||
private fun menuEntry(context: Context, id: String) =
|
||||
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
|
||||
|
||||
fun launches(program: String?): Boolean =
|
||||
program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER
|
||||
|
||||
fun list(context: Context): List<App> {
|
||||
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
|
||||
App(
|
||||
|
||||
@@ -77,7 +77,7 @@ class PerfHud(context: Context) {
|
||||
val mode = FrameGen.mode(context)
|
||||
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
|
||||
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
|
||||
val name = if (lsfg) "LSFG" else "Win-FG"
|
||||
val name = if (lsfg) "LSFG" else "DD-FG"
|
||||
val label = FrameGen.label(context, mode)
|
||||
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
|
||||
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
|
||||
|
||||
@@ -66,6 +66,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
|
||||
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
|
||||
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
|
||||
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
|
||||
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
|
||||
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
|
||||
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
|
||||
@@ -133,7 +134,9 @@ object SessionFiles {
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
|
||||
) + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
|
||||
) + listOf("xodus-cli", "xodus-service", "LICENSE.xodus", "LICENSE.nexus", "LICENSE.LGPL-2.1", "LICENSE.nghttp2", "backend.lock.json", "runtime.lock.json", "version",
|
||||
"windows/xgameruntime.dll", "windows/winhttp.dll", "windows/windows.web.dll", "windows/twinapi.appcore.dll", "windows/libnghttp2.dll")
|
||||
.map { "usr/local/lib/droiddeck-xbox/$it" to "usr/local/lib/droiddeck-xbox/$it" } + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
|
||||
val STORES_VIEWS = listOf("cards", "list")
|
||||
val STORES_VIEWS = listOf("cards", "capsules", "list")
|
||||
|
||||
const val STORES_OPEN_LIBRARY = "library"
|
||||
const val STORES_OPEN_STORE = "store"
|
||||
@@ -878,6 +878,11 @@ object SessionPrefs {
|
||||
|
||||
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
|
||||
|
||||
fun displayMode(context: Context, mode: String, program: String?): String =
|
||||
if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) &&
|
||||
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
|
||||
else prefMode(mode)
|
||||
|
||||
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
|
||||
|
||||
fun setPipAutoEnter(context: Context, enabled: Boolean) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONObject
|
||||
@@ -57,7 +58,7 @@ object CloudSaves {
|
||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
||||
}
|
||||
Store.AMAZON -> ""
|
||||
Store.AMAZON, Store.ITCH, Store.XBOX -> ""
|
||||
} ?: return null
|
||||
p.edit().putString("cloud_template_$id", fetched).apply()
|
||||
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
|
||||
@@ -95,7 +96,7 @@ object CloudSaves {
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH, Store.XBOX -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
@@ -120,7 +121,7 @@ object CloudSaves {
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH, Store.XBOX -> null
|
||||
}
|
||||
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
@@ -150,19 +151,22 @@ object CloudSaves {
|
||||
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
|
||||
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
|
||||
|
||||
fun downloadOrDefer(context: Context, store: Store, id: String): Result {
|
||||
fun downloadOrDefer(context: Context, store: Store, id: String): Result =
|
||||
downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() })
|
||||
|
||||
internal fun downloadOrDefer(context: Context, store: Store, id: String, executor: Executor): Result {
|
||||
val app = context.applicationContext
|
||||
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
|
||||
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
|
||||
Thread({
|
||||
executor.execute {
|
||||
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
|
||||
while (System.currentTimeMillis() < end) {
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
|
||||
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@execute }
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
|
||||
}, "cloud-deferred").start()
|
||||
}
|
||||
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ internal object LoginFlows {
|
||||
Store.GOG -> GogFlow(state)
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
Store.ITCH, Store.XBOX -> null
|
||||
}
|
||||
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.stores
|
||||
enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
GOG("gog", "GOG", "GOG"),
|
||||
EPIC("epic", "Epic Games", "Epic"),
|
||||
AMAZON("amazon", "Amazon Games", "Amazon");
|
||||
AMAZON("amazon", "Amazon Games", "Amazon"),
|
||||
XBOX("xbox", "Xbox", "Xbox"),
|
||||
ITCH("itch", "itch.io", "itch");
|
||||
|
||||
companion object {
|
||||
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
|
||||
|
||||
@@ -67,7 +67,10 @@ object StoreAccounts {
|
||||
fun write(context: Context, store: Store, json: JSONObject) {
|
||||
unavailable.remove(store)
|
||||
val f = credentialsFile(context, store)
|
||||
if (seal(f, json) == null) put(f, json.toString())
|
||||
if (seal(f, json, retry = store == Store.XBOX) == null) {
|
||||
check(store != Store.XBOX) { "Android Keystore is unavailable for Xbox credentials" }
|
||||
put(f, json.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -86,18 +89,19 @@ object StoreAccounts {
|
||||
|
||||
fun signedInAs(context: Context, store: Store): String? {
|
||||
val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null
|
||||
if (store == Store.XBOX) return json.optString("username").takeIf { it.isNotBlank() }
|
||||
if (json.optString("access_token", "").isEmpty()) return null
|
||||
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
|
||||
}
|
||||
|
||||
private fun seal(f: File, json: JSONObject): File? {
|
||||
if (keystoreFailed) return null
|
||||
private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? {
|
||||
if (keystoreFailed && !retry) return null
|
||||
val text = json.toString()
|
||||
val envelope = try {
|
||||
CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } }
|
||||
} catch (e: Exception) {
|
||||
keystoreFailed = true
|
||||
Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})")
|
||||
Log.w(TAG, "stores: keystore unavailable (${e.javaClass.simpleName})")
|
||||
return null
|
||||
}
|
||||
put(f, envelope.toString())
|
||||
|
||||
@@ -21,7 +21,9 @@ data class CatalogItem(
|
||||
val sizeBytes: Long = 0L,
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
val mature: Boolean = false,
|
||||
val subscription: Boolean = false,
|
||||
) {
|
||||
val accessible: Boolean get() = owned || subscription
|
||||
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
|
||||
val key: String get() = "${store.id}:$id"
|
||||
}
|
||||
@@ -49,7 +51,9 @@ fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: L
|
||||
return installed.filter { it.sidecar.store == store }.map { game ->
|
||||
val s = game.sidecar
|
||||
byId[s.id] ?: byTitle[s.title.lowercase()]
|
||||
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true)
|
||||
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero,
|
||||
owned = s.store != Store.XBOX || s.extra["access"] == "owned",
|
||||
subscription = s.store == Store.XBOX && s.extra["access"] == "gamepass")
|
||||
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.File
|
||||
|
||||
@@ -63,6 +64,14 @@ object StoreLaunch {
|
||||
private const val CODE_VAR = "DD_X"
|
||||
private const val CLIENT_VAR = "DD_A"
|
||||
|
||||
fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? {
|
||||
val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder ->
|
||||
StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true
|
||||
} ?: return null
|
||||
val game = AddedGames.single(context, folder.path) ?: return null
|
||||
return Library.addedGame(context, game)
|
||||
}
|
||||
|
||||
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
|
||||
if (sidecar.launcher != LAUNCHER) return
|
||||
val file = File(folder, LAUNCHER)
|
||||
|
||||
@@ -37,6 +37,7 @@ object StoreLaunchRequests {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start()
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
@@ -50,6 +51,12 @@ object StoreLaunchRequests {
|
||||
return
|
||||
}
|
||||
val reply = when (request.optString("op")) {
|
||||
"xbox-released" -> {
|
||||
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id"))
|
||||
JSONObject().put("ok", true)
|
||||
}
|
||||
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
|
||||
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
|
||||
"epic-code" -> {
|
||||
|
||||
@@ -31,7 +31,7 @@ object StoreSizes {
|
||||
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
|
||||
|
||||
fun request(context: Context, item: CatalogItem) {
|
||||
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
|
||||
if (!item.accessible || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
|
||||
resolve(context, item).thenAccept { bytes ->
|
||||
if (bytes != null) StoresState.post { known[item.key] = bytes }
|
||||
}
|
||||
@@ -39,7 +39,7 @@ object StoreSizes {
|
||||
|
||||
fun resolve(context: Context, item: CatalogItem): CompletableFuture<Long?> {
|
||||
val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 }
|
||||
if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
|
||||
if (!item.accessible || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
|
||||
if (available != null) return CompletableFuture.completedFuture(available)
|
||||
val app = context.applicationContext
|
||||
val version = item.extra["version"].orEmpty()
|
||||
@@ -54,7 +54,8 @@ object StoreSizes {
|
||||
when (item.store) {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.GOG -> {}
|
||||
Store.ITCH -> {}
|
||||
Store.GOG, Store.XBOX -> {}
|
||||
}
|
||||
}
|
||||
}, pool).exceptionally { error ->
|
||||
@@ -107,6 +108,7 @@ object StoreSizes {
|
||||
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
|
||||
AmazonManifest.parse(bytes).totalInstallSize
|
||||
}
|
||||
Store.GOG -> 0L
|
||||
Store.GOG, Store.XBOX -> 0L
|
||||
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||
} }
|
||||
}
|
||||
@@ -16,14 +16,19 @@ import java.util.Locale
|
||||
|
||||
object StoresState {
|
||||
private const val TAG = "Stores"
|
||||
private const val SEARCH_DELAY_MS = 400L
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
val accounts = mutableStateMapOf<Store, String>()
|
||||
var accountsLoaded by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val library = mutableStateMapOf<Store, List<CatalogItem>>()
|
||||
|
||||
val shelves = mutableStateMapOf<Store, StoreShelves>()
|
||||
|
||||
val found = mutableStateMapOf<Store, List<CatalogItem>>()
|
||||
|
||||
val status = mutableStateMapOf<Store, String>()
|
||||
|
||||
val problems = mutableStateMapOf<Store, String>()
|
||||
@@ -64,6 +69,8 @@ object StoresState {
|
||||
|
||||
private val backends = HashMap<Store, StoreBackend>()
|
||||
|
||||
private val searchSeq = java.util.concurrent.ConcurrentHashMap<Store, Int>()
|
||||
|
||||
fun backend(store: Store): StoreBackend? = backends[store]
|
||||
|
||||
fun register(backend: StoreBackend) { backends[backend.store] = backend }
|
||||
@@ -79,11 +86,15 @@ object StoresState {
|
||||
val partial = StoreInstallRoot.unfinished(app)
|
||||
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
|
||||
main.post {
|
||||
accounts.clear()
|
||||
found.forEach { (store, name) -> accounts[store] = name }
|
||||
installed = onDisk
|
||||
unfinished = partial
|
||||
engine = version
|
||||
androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot {
|
||||
val restored = found.toMap()
|
||||
accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) }
|
||||
accounts.putAll(restored)
|
||||
installed = onDisk
|
||||
unfinished = partial
|
||||
engine = version
|
||||
accountsLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +140,26 @@ object StoresState {
|
||||
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
|
||||
}
|
||||
|
||||
fun search(context: Context, store: Store, query: String) {
|
||||
val backend = backends[store] ?: return
|
||||
val text = query.trim()
|
||||
if (text.isEmpty()) {
|
||||
searchSeq.remove(store)
|
||||
found.remove(store)
|
||||
return
|
||||
}
|
||||
val seq = (searchSeq[store] ?: 0) + 1
|
||||
searchSeq[store] = seq
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
Thread.sleep(SEARCH_DELAY_MS)
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList())
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
main.post { found[store] = items }
|
||||
}, "stores-search").start()
|
||||
}
|
||||
|
||||
fun signIn(context: Context, store: Store) {
|
||||
expired.remove(store)
|
||||
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
|
||||
@@ -265,6 +296,7 @@ interface StoreBackend {
|
||||
fun signOut(context: Context)
|
||||
fun syncLibrary(context: Context, force: Boolean)
|
||||
fun loadShelves(context: Context, force: Boolean)
|
||||
fun search(context: Context, query: String): List<CatalogItem> = emptyList()
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File)
|
||||
fun uninstall(context: Context, game: InstalledStoreGame)
|
||||
}
|
||||
@@ -335,7 +335,7 @@ object DownloadQueue {
|
||||
StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
|
||||
StoresState.notifyLibraryChanged(context)
|
||||
val games = runCatching {
|
||||
com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
}.getOrNull()
|
||||
StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ object SavedQueue {
|
||||
put("store", i.store.id); put("id", i.id); put("title", i.title)
|
||||
put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
|
||||
put("tags", i.tags); put("developer", i.developer); put("description", i.description)
|
||||
put("size", i.sizeBytes); put("owned", i.owned)
|
||||
put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription)
|
||||
put("extra", JSONObject(i.extra))
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ object SavedQueue {
|
||||
id = o.getString("id"), title = o.getString("title"),
|
||||
imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null },
|
||||
tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"),
|
||||
owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"),
|
||||
owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), sizeBytes = o.optLong("size"),
|
||||
extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchApi {
|
||||
const val KEYS_PAGE = "https://itch.io/user/settings/api-keys"
|
||||
|
||||
private const val BASE = "https://api.itch.io"
|
||||
private const val WEB = "https://itch.io"
|
||||
|
||||
fun get(path: String, key: String): JSONObject? {
|
||||
val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null
|
||||
return runCatching { JSONObject(body) }.getOrNull()
|
||||
}
|
||||
|
||||
fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user")
|
||||
|
||||
fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
|
||||
|
||||
fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
|
||||
get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads")
|
||||
|
||||
fun search(key: String, query: String, page: Int): JSONArray? {
|
||||
val q = java.net.URLEncoder.encode(query, "UTF-8")
|
||||
return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games")
|
||||
}
|
||||
|
||||
fun downloadUrl(uploadId: String, downloadKeyId: String): String =
|
||||
"$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId"
|
||||
|
||||
fun pageUrl(slug: String): String = "$WEB/$slug"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchAuth {
|
||||
private val TOKEN = Regex("[A-Za-z0-9]{40,64}")
|
||||
|
||||
fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty()
|
||||
|
||||
fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty()
|
||||
|
||||
fun verify(key: String): JSONObject? = ItchApi.profile(key)
|
||||
|
||||
fun save(context: Context, key: String, user: JSONObject) {
|
||||
StoreAccounts.write(
|
||||
context,
|
||||
Store.ITCH,
|
||||
JSONObject()
|
||||
.put("access_token", key)
|
||||
.put("username", user.optString("username"))
|
||||
.put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") })
|
||||
.put("user_id", user.optLong("id")),
|
||||
)
|
||||
}
|
||||
|
||||
fun candidates(text: String): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) }
|
||||
TOKEN.findAll(text).forEach { out.add(it.value) }
|
||||
return out.take(8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreBackend
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreSizes
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import java.io.File
|
||||
|
||||
object ItchBackend : StoreBackend {
|
||||
override val store: Store = Store.ITCH
|
||||
|
||||
override fun signIn(context: Context) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
override fun signOut(context: Context) {
|
||||
ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
|
||||
StoresState.post {
|
||||
StoresState.library.remove(store)
|
||||
StoresState.shelves.remove(store)
|
||||
StoresState.found.remove(store)
|
||||
StoresState.problems.remove(store)
|
||||
StoresState.status.remove(store)
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncLibrary(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } }
|
||||
Thread({
|
||||
val shelves = ItchShelves.featured(app, force)
|
||||
if (shelves != null) {
|
||||
StoresState.post { StoresState.shelves[store] = shelves }
|
||||
} else {
|
||||
StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) }
|
||||
}
|
||||
}, "itch-shelves").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = ItchShelves.search(context, query)
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
if (item.extra["downloadKeyId"].isNullOrEmpty()) {
|
||||
StoresState.logLine("itch.io: \"${item.title}\" is not in the library")
|
||||
return
|
||||
}
|
||||
val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root)
|
||||
val entry = DownloadEntry(
|
||||
Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item),
|
||||
location = StoreInstallRoot.labelFor(app, folder),
|
||||
)
|
||||
DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) }
|
||||
}
|
||||
|
||||
override fun uninstall(context: Context, game: InstalledStoreGame) {}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchCatalog {
|
||||
fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/')
|
||||
|
||||
fun shelvesJson(shelves: StoreShelves): String = JSONObject()
|
||||
.put("new", array(shelves.whatsNew))
|
||||
.put("deals", array(shelves.deals))
|
||||
.put("free", array(shelves.free))
|
||||
.put("top", array(shelves.trending))
|
||||
.toString()
|
||||
|
||||
fun shelves(raw: String?): StoreShelves? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val shelves = StoreShelves(
|
||||
whatsNew = list(o.optJSONArray("new")),
|
||||
deals = list(o.optJSONArray("deals")),
|
||||
free = list(o.optJSONArray("free")),
|
||||
trending = list(o.optJSONArray("top")),
|
||||
)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
fun array(items: List<CatalogItem>): JSONArray {
|
||||
val out = JSONArray()
|
||||
items.forEach { out.put(json(it)) }
|
||||
return out
|
||||
}
|
||||
|
||||
fun list(raw: JSONArray?): List<CatalogItem> {
|
||||
if (raw == null) return emptyList()
|
||||
return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) }
|
||||
}
|
||||
|
||||
private fun json(i: CatalogItem): JSONObject = JSONObject()
|
||||
.put("id", i.id)
|
||||
.put("title", i.title)
|
||||
.put("image", i.imageUrl ?: "")
|
||||
.put("tall", i.tallImageUrl ?: "")
|
||||
.put("tags", i.tags)
|
||||
.put("free", i.isFree)
|
||||
.put("hasPrice", i.hasPrice)
|
||||
.put("finalPrice", i.finalPrice)
|
||||
.put("originalPrice", i.originalPrice)
|
||||
.put("discount", i.discountPercent)
|
||||
.put("url", i.storeUrl)
|
||||
.put("developer", i.developer)
|
||||
.put("publisher", i.publisher)
|
||||
.put("releaseDate", i.releaseDate)
|
||||
.put("description", i.description)
|
||||
.put("owned", i.owned)
|
||||
.put("size", i.sizeBytes)
|
||||
.put("extra", JSONObject(i.extra))
|
||||
.put("mature", i.mature)
|
||||
|
||||
private fun item(o: JSONObject): CatalogItem {
|
||||
val extra = o.optJSONObject("extra")
|
||||
val map = HashMap<String, String>()
|
||||
extra?.keys()?.forEach { k -> map[k] = extra.optString(k) }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = o.optString("id"),
|
||||
title = o.optString("title"),
|
||||
imageUrl = o.optString("image").ifBlank { null },
|
||||
tallImageUrl = o.optString("tall").ifBlank { null },
|
||||
tags = o.optString("tags"),
|
||||
isFree = o.optBoolean("free"),
|
||||
hasPrice = o.optBoolean("hasPrice"),
|
||||
finalPrice = o.optString("finalPrice"),
|
||||
originalPrice = o.optString("originalPrice"),
|
||||
discountPercent = o.optInt("discount"),
|
||||
storeUrl = o.optString("url"),
|
||||
developer = o.optString("developer"),
|
||||
publisher = o.optString("publisher"),
|
||||
releaseDate = o.optString("releaseDate"),
|
||||
description = o.optString("description"),
|
||||
owned = o.optBoolean("owned"),
|
||||
sizeBytes = o.optLong("size"),
|
||||
extra = map,
|
||||
mature = o.optBoolean("mature"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.GuestCommand
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreExe
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal class ItchInstaller(
|
||||
private val app: Context,
|
||||
private val item: CatalogItem,
|
||||
private val folder: File,
|
||||
) : DownloadQueue.DownloadJob {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
val key = ItchAuth.key(app)
|
||||
if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in))
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty()
|
||||
if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned))
|
||||
val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') }
|
||||
StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl)
|
||||
handle.stage(DownloadStage.MANIFEST)
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId))
|
||||
?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload))
|
||||
val uploadId = upload.optString("id")
|
||||
val filename = ItchUploads.filename(upload)
|
||||
val size = ItchUploads.size(upload)
|
||||
val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)
|
||||
scratch.mkdirs()
|
||||
val archive = File(scratch, filename.ifBlank { "download.bin" })
|
||||
val exe = try {
|
||||
handle.stage(DownloadStage.DOWNLOAD)
|
||||
download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle)
|
||||
if (cancelled.get()) return null
|
||||
handle.stage(DownloadStage.INSTALL)
|
||||
install(archive, filename, handle)
|
||||
} catch (t: Throwable) {
|
||||
if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw t
|
||||
}
|
||||
if (exe == null) {
|
||||
if (cancelled.get()) return null
|
||||
StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_no_exe))
|
||||
}
|
||||
archive.delete()
|
||||
StoreInstalls.deleteTree(scratch)
|
||||
val sidecar = StoreGameSidecar(
|
||||
store = Store.ITCH,
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
exe = exe,
|
||||
installVersion = upload.optString("updated_at", upload.optString("updatedAt")),
|
||||
installedAt = System.currentTimeMillis(),
|
||||
cover = item.imageUrl,
|
||||
hero = item.imageUrl,
|
||||
extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId),
|
||||
cloud = false,
|
||||
)
|
||||
StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }
|
||||
return folder.path
|
||||
}
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)))
|
||||
}
|
||||
|
||||
private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long,
|
||||
handle: DownloadQueue.JobHandle) {
|
||||
// A partial file is only resumed when it came from this exact upload build.
|
||||
val origin = File(target.path + ".from")
|
||||
val stamp = "$uploadId $version $expected"
|
||||
if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) {
|
||||
target.delete()
|
||||
origin.writeText(stamp)
|
||||
}
|
||||
if (expected > 0 && target.length() == expected) {
|
||||
handle.progress(expected, expected)
|
||||
return
|
||||
}
|
||||
for (attempt in 0..1) {
|
||||
val have = target.length()
|
||||
var conn: HttpURLConnection? = null
|
||||
try {
|
||||
conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 30_000
|
||||
readTimeout = 60_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Authorization", "Bearer $key")
|
||||
setRequestProperty("Accept", "*/*")
|
||||
if (have > 0) setRequestProperty("Range", "bytes=$have-")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
if (code == 416 && attempt == 0) {
|
||||
target.delete()
|
||||
continue
|
||||
}
|
||||
if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
val append = code == 206 && have > 0
|
||||
if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over")
|
||||
var done = if (append) have else 0L
|
||||
val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected
|
||||
// The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput.
|
||||
handle.progress(done, total)
|
||||
val buffer = ByteArray(128 * 1024)
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(target, append).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
handle.progress(done, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
return
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? {
|
||||
folder.mkdirs()
|
||||
when (val kind = sniff(archive, filename)) {
|
||||
"zip" -> unzip(archive, handle)
|
||||
"7z" -> sevenZip(archive, handle)
|
||||
"rar" -> unrar(archive, handle)
|
||||
"exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" }))
|
||||
"tar" -> untar(archive, null, handle)
|
||||
"gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle)
|
||||
else -> copy(archive, File(folder, filename.ifBlank { "game" }))
|
||||
}
|
||||
val hint = filename.substringBeforeLast('.')
|
||||
val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title)
|
||||
if (exe.isBlank()) {
|
||||
handle.log("itch.io: no exe found in ${folder.name}")
|
||||
return null
|
||||
}
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun sniff(file: File, filename: String): String {
|
||||
val extension = filename.substringAfterLast('.', "").lowercase()
|
||||
if (extension == "exe") return "exe"
|
||||
val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull()
|
||||
?: return extension
|
||||
fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() }
|
||||
return when {
|
||||
at(0, 0x50, 0x4B) -> "zip"
|
||||
at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z"
|
||||
at(0, 0x52, 0x61, 0x72, 0x21) -> "rar"
|
||||
at(0, 0x1F, 0x8B) -> "gz"
|
||||
at(0, 0x42, 0x5A, 0x68) -> "bz2"
|
||||
at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz"
|
||||
at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar"
|
||||
else -> extension
|
||||
}
|
||||
}
|
||||
|
||||
private fun target(name: String): File? {
|
||||
val file = File(folder, name.replace('\\', '/'))
|
||||
val root = folder.canonicalPath
|
||||
val path = file.canonicalPath
|
||||
return if (path == root || path.startsWith(root + File.separator)) file else null
|
||||
}
|
||||
|
||||
// No JVM library reads RAR5; the runtime's bsdtar (libarchive) does.
|
||||
private fun unrar(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar"))
|
||||
val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null
|
||||
else listOf(folder.absolutePath)
|
||||
fun bsdtar(args: List<String>, onLine: (String) -> Unit) =
|
||||
GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line ->
|
||||
if (!line.startsWith("proot ")) onLine(line)
|
||||
}
|
||||
var entries = 0L
|
||||
bsdtar(listOf("-tf", archive.absolutePath)) { entries++ }
|
||||
var done = 0L
|
||||
val tail = ArrayDeque<String>()
|
||||
val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line ->
|
||||
if (line.startsWith("x ")) handle.stageProgress(++done, entries) else {
|
||||
if (tail.size == 5) tail.removeFirst()
|
||||
tail.addLast(line)
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) return
|
||||
if (code != 0) {
|
||||
handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}")
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
SevenZFile.builder().setFile(archive).get().use { seven ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val entry = seven.nextEntry ?: break
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = seven.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
var entries = 0
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
val opened = runCatching {
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"gz" -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> BufferedInputStream(raw)
|
||||
}
|
||||
stream.use { input ->
|
||||
TarArchiveInputStream(input).use { tar ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val entry = tar.nextEntry ?: break
|
||||
entries++
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val read = tar.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
if (opened.isSuccess && entries > 0) return Unit
|
||||
if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) {
|
||||
val name = archive.name.substringBeforeLast('.')
|
||||
val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive")
|
||||
file.parentFile?.mkdirs()
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
}
|
||||
stream.use { input ->
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copy(source: File, target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } }
|
||||
}
|
||||
|
||||
// Streaming readers stop silently on zips whose data descriptors disagree with the local headers;
|
||||
// the central directory is authoritative.
|
||||
private fun unzip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
ZipFile.builder().setFile(archive).get().use { zip ->
|
||||
for (entry in zip.entries) {
|
||||
if (cancelled.get()) return
|
||||
val target = target(entry.name)
|
||||
if (target == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
target.mkdirs()
|
||||
continue
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import com.droiddeck.launcher.stores.mergeItems
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object ItchLibrary {
|
||||
private const val KEY_CACHE = "library_cache"
|
||||
private const val KEY_AT = "library_synced_at"
|
||||
private const val MAX_PAGES = 20
|
||||
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
|
||||
|
||||
private val running = AtomicBoolean(false)
|
||||
|
||||
fun cached(context: Context): List<CatalogItem> {
|
||||
val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
|
||||
return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun sync(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
try {
|
||||
val key = ItchAuth.key(app)
|
||||
val cache = cached(app)
|
||||
val prefs = ItchPrefs.get(app)
|
||||
if (key.isBlank()) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in)
|
||||
}
|
||||
return
|
||||
}
|
||||
val quiet = cache.isNotEmpty() && !force
|
||||
if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) {
|
||||
StoresState.post { StoresState.library[Store.ITCH] = cache }
|
||||
return
|
||||
}
|
||||
if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) }
|
||||
val items = fetch(key)
|
||||
if (items == null) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed)
|
||||
}
|
||||
return
|
||||
}
|
||||
prefs.edit()
|
||||
.putString(KEY_CACHE, ItchCatalog.array(items).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis())
|
||||
.apply()
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
StoresState.problems.remove(Store.ITCH)
|
||||
StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items)
|
||||
}
|
||||
StoresState.logLine("itch.io: ${items.size} owned games")
|
||||
} finally {
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun size(context: Context, item: CatalogItem): Long {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return 0L
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L }
|
||||
val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L }
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L
|
||||
return ItchUploads.size(upload)
|
||||
}
|
||||
|
||||
private fun fetch(key: String): List<CatalogItem>? {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
var page = 1
|
||||
var keys = 0
|
||||
while (page <= MAX_PAGES) {
|
||||
val body = ItchApi.ownedKeys(key, page) ?: return null
|
||||
val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out
|
||||
if (list.length() == 0) break
|
||||
out.addAll(items(list))
|
||||
keys += list.length()
|
||||
page++
|
||||
}
|
||||
val games = out.distinctBy { it.id }
|
||||
StoresState.logLine("itch.io: $keys download keys, ${games.size} games")
|
||||
return games
|
||||
}
|
||||
|
||||
internal fun items(keys: JSONArray): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>(keys.length())
|
||||
for (i in 0 until keys.length()) {
|
||||
val entry = keys.optJSONObject(i) ?: continue
|
||||
val game = entry.optJSONObject("game") ?: continue
|
||||
item(game, entry.optString("id"))?.let { out.add(it) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.text(snake: String, camel: String): String =
|
||||
optString(snake).ifBlank { optString(camel) }
|
||||
|
||||
internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? {
|
||||
val gameId = game.optLong("id")
|
||||
if (gameId <= 0L) return null
|
||||
val user = game.optJSONObject("user")
|
||||
val url = game.optString("url").ifBlank {
|
||||
val owner = user?.optString("username").orEmpty()
|
||||
if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId")
|
||||
}
|
||||
val traits = game.optJSONArray("traits")?.let { arr ->
|
||||
(0 until arr.length()).map { arr.optString(it) }
|
||||
}.orEmpty()
|
||||
val platforms = game.optJSONObject("platforms")
|
||||
val sale = game.optJSONObject("sale")
|
||||
val rate = sale?.optDouble("rate", 0.0) ?: 0.0
|
||||
val cents = game.optLong("min_price", game.optLong("minPrice"))
|
||||
val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits))
|
||||
val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0
|
||||
val price = if (canBuy && cents > 0) money(cents) else ""
|
||||
val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else ""
|
||||
val tags = buildList {
|
||||
if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows")
|
||||
if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux")
|
||||
if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS")
|
||||
}.joinToString(", ")
|
||||
val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") }
|
||||
val tall = game.text("still_cover_url", "stillCoverUrl")
|
||||
val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(url).ifBlank { gameId.toString() },
|
||||
title = game.optString("title").ifBlank { "itch.io game $gameId" },
|
||||
imageUrl = cover.ifBlank { null },
|
||||
tallImageUrl = tall.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = price.isEmpty(),
|
||||
hasPrice = price.isNotEmpty(),
|
||||
finalPrice = salePrice.ifEmpty { price },
|
||||
originalPrice = if (salePrice.isNotEmpty()) price else "",
|
||||
discountPercent = if (salePrice.isNotEmpty()) discount else 0,
|
||||
storeUrl = url,
|
||||
developer = author,
|
||||
publisher = author,
|
||||
releaseDate = game.text("published_at", "publishedAt").take(10),
|
||||
description = cleanStoreText(game.text("short_text", "shortText")),
|
||||
owned = downloadKeyId.isNotBlank(),
|
||||
extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()),
|
||||
)
|
||||
}
|
||||
|
||||
private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0')
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object ItchPrefs {
|
||||
const val FILE = "stores.itch"
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal object ItchShelves {
|
||||
private const val TAG = "ItchShelves"
|
||||
private const val KEY = "shelves_cache"
|
||||
private const val KEY_AT = "shelves_at"
|
||||
private const val FRESH_MS = 3 * 60 * 60 * 1000L
|
||||
private const val GAP_MS = 1_200L
|
||||
private const val RETRY_MS = 20_000L
|
||||
|
||||
private val FEEDS = listOf(
|
||||
"https://itch.io/games/newest.xml",
|
||||
"https://itch.io/games/on-sale.xml",
|
||||
"https://itch.io/games/price-free.xml",
|
||||
"https://itch.io/games/top-rated.xml",
|
||||
)
|
||||
private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile")
|
||||
|
||||
@Volatile
|
||||
private var memory: StoreShelves? = null
|
||||
|
||||
fun cached(context: Context): StoreShelves? {
|
||||
memory?.let { return it }
|
||||
val stored = stored(ItchPrefs.get(context.applicationContext))
|
||||
if (stored != null) memory = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
fun featured(context: Context, force: Boolean): StoreShelves? {
|
||||
val prefs = ItchPrefs.get(context.applicationContext)
|
||||
val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS
|
||||
if (fresh && !force) {
|
||||
cached(context)?.let { return it }
|
||||
}
|
||||
val fetched = fetch()
|
||||
if (fetched != null) {
|
||||
memory = fetched
|
||||
prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
return fetched
|
||||
}
|
||||
return cached(context)
|
||||
}
|
||||
|
||||
fun search(context: Context, query: String): List<CatalogItem> {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return emptyList()
|
||||
val out = ArrayList<CatalogItem>()
|
||||
for (page in 1..2) {
|
||||
val games = ItchApi.search(key, query, page) ?: break
|
||||
if (games.length() == 0) break
|
||||
for (i in 0 until games.length()) {
|
||||
val game = games.optJSONObject(i) ?: continue
|
||||
ItchLibrary.item(game, "")?.let { out.add(it) }
|
||||
}
|
||||
if (games.length() < 30) break
|
||||
Thread.sleep(GAP_MS)
|
||||
}
|
||||
return out.distinctBy { it.id }
|
||||
}
|
||||
|
||||
private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null))
|
||||
|
||||
private fun fetch(): StoreShelves? {
|
||||
val lists = ArrayList<List<CatalogItem>>(FEEDS.size)
|
||||
var answered = false
|
||||
for ((index, url) in FEEDS.withIndex()) {
|
||||
if (index > 0) Thread.sleep(GAP_MS)
|
||||
var (code, body) = open(url)
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed 429; backing off")
|
||||
Thread.sleep(RETRY_MS)
|
||||
val again = open(url)
|
||||
code = again.first
|
||||
body = again.second
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed still 429; keeping the cached shelves")
|
||||
lists.add(emptyList())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (code in 200..299 && body != null) {
|
||||
answered = true
|
||||
lists.add(parse(body))
|
||||
} else {
|
||||
lists.add(emptyList())
|
||||
}
|
||||
}
|
||||
if (!answered) return null
|
||||
while (lists.size < FEEDS.size) lists.add(emptyList())
|
||||
val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12)
|
||||
val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
private fun open(url: String): Pair<Int, String?> {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "feed ${e.javaClass.simpleName}")
|
||||
0 to null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parse(xml: String): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(StringReader(xml))
|
||||
var event = parser.eventType
|
||||
var field: String? = null
|
||||
var current: HashMap<String, String>? = null
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
if (parser.name == "item") current = HashMap()
|
||||
field = parser.name
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val map = current
|
||||
val name = field
|
||||
if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name == "item") {
|
||||
current?.let { item(it)?.let(out::add) }
|
||||
current = null
|
||||
}
|
||||
field = null
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun item(m: Map<String, String>): CatalogItem? {
|
||||
val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() }
|
||||
if (link.isBlank()) return null
|
||||
val title = m["plainTitle"]?.trim().orEmpty()
|
||||
.ifBlank { m["title"].orEmpty().substringBefore(" [").trim() }
|
||||
.ifBlank { ItchCatalog.slug(link) }
|
||||
val price = m["price"]?.trim().orEmpty()
|
||||
val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true)
|
||||
val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty())
|
||||
.map { it.groupValues[1].trim() }
|
||||
.filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS }
|
||||
.joinToString(", ")
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(link),
|
||||
title = title,
|
||||
imageUrl = m["imageurl"]?.trim()?.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = free,
|
||||
hasPrice = !free,
|
||||
finalPrice = if (free) "" else price,
|
||||
originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(),
|
||||
discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0,
|
||||
storeUrl = link,
|
||||
releaseDate = date(m["pubDate"]),
|
||||
description = cleanStoreText(m["description"]),
|
||||
)
|
||||
}
|
||||
|
||||
private fun date(raw: String?): String {
|
||||
val text = raw?.trim().orEmpty()
|
||||
if (text.isBlank()) return ""
|
||||
return runCatching {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text)
|
||||
}.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchUploads {
|
||||
private val GAME_TYPES = setOf("default", "flash", "unity", "java", "html", "")
|
||||
private val PLATFORM_TRAITS = setOf("p_windows", "p_linux", "p_osx", "p_android")
|
||||
|
||||
fun windows(uploads: JSONArray?): JSONObject? {
|
||||
if (uploads == null) return null
|
||||
val all = (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) }
|
||||
.filter { it.optString("storage") != "external" && !it.optBoolean("demo") }
|
||||
if (all.isEmpty()) return null
|
||||
val games = all.filter { it.optString("type") in GAME_TYPES }
|
||||
val preferred = games.ifEmpty { all }
|
||||
val candidates = preferred.filter { windows(it) }.ifEmpty { preferred }
|
||||
return candidates.maxWithOrNull(compareBy({ it.optString("updated_at", it.optString("updatedAt")) }, { it.optLong("id") }))
|
||||
}
|
||||
|
||||
fun windows(upload: JSONObject): Boolean {
|
||||
val platforms = upload.optJSONObject("platforms")
|
||||
if (platforms != null) {
|
||||
if (platforms.optBoolean("windows")) return true
|
||||
if (platforms.optBoolean("linux") || platforms.optBoolean("osx") || platforms.optBoolean("android")) return false
|
||||
}
|
||||
val traits = upload.optJSONArray("traits") ?: return true
|
||||
val names = (0 until traits.length()).map { traits.optString(it) }
|
||||
if (names.any { it == "p_windows" }) return true
|
||||
return names.none { it in PLATFORM_TRAITS }
|
||||
}
|
||||
|
||||
fun size(upload: JSONObject): Long = upload.optLong("size", upload.optLong("size_bytes"))
|
||||
|
||||
fun filename(upload: JSONObject): String = upload.optString("filename").substringAfterLast('/')
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.*
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import java.io.File
|
||||
|
||||
object XboxBackend : StoreBackend {
|
||||
override val store = Store.XBOX
|
||||
@Volatile private var access: Map<String, String> = emptyMap()
|
||||
@Volatile private var generation = 0
|
||||
private var syncing: Int? = null
|
||||
|
||||
override fun signIn(context: Context) {
|
||||
context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
@Synchronized override fun signOut(context: Context) {
|
||||
generation++
|
||||
XboxCredentials.invalidate(context)
|
||||
XboxLibraryCache.clear(context)
|
||||
XboxRuntime.stop()
|
||||
access = emptyMap()
|
||||
StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) }
|
||||
}
|
||||
|
||||
@Synchronized override fun syncLibrary(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
val current = generation
|
||||
if (syncing == current) return
|
||||
syncing = current
|
||||
Thread({
|
||||
try {
|
||||
val account = StoreAccounts.signedInAs(app, store) ?: error("Sign in to Xbox first.")
|
||||
val market = Xodus.market()
|
||||
val cached = XboxLibraryCache.read(app, account, market)
|
||||
synchronized(this) {
|
||||
if (current != generation) return@Thread
|
||||
if (cached != null) access = cached.games.associate { it.id to it.extra.getValue("access") }
|
||||
}
|
||||
StoresState.post {
|
||||
if (current == generation) {
|
||||
if (cached != null) StoresState.library[store] = mergeItems(StoresState.library[store], cached.games)
|
||||
StoresState.problems.remove(store)
|
||||
if (cached == null || force) StoresState.status[store] = "Syncing Xbox library…"
|
||||
else StoresState.status.remove(store)
|
||||
}
|
||||
}
|
||||
if (cached != null && !cached.needsRefresh(force)) return@Thread
|
||||
val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market)
|
||||
val games = XboxCatalog.products(licenses.keys.toList(), licenses, market).sortedBy { it.title.lowercase() }
|
||||
synchronized(this) {
|
||||
if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread
|
||||
access = licenses
|
||||
runCatching { XboxLibraryCache.write(app, account, market, games) }
|
||||
.onFailure { StoresState.logLine("Xbox: library cache could not be saved") }
|
||||
}
|
||||
StoresState.post {
|
||||
if (current == generation) {
|
||||
StoresState.library[store] = mergeItems(StoresState.library[store], games)
|
||||
StoresState.status.remove(store)
|
||||
StoresState.problems.remove(store)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
StoresState.post {
|
||||
if (current == generation) {
|
||||
StoresState.status.remove(store)
|
||||
StoresState.problems[store] = e.message ?: "Xbox library could not be loaded."
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized(this) { if (syncing == current) syncing = null }
|
||||
}
|
||||
}, "xbox-library").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val current = generation
|
||||
Thread({
|
||||
runCatching { XboxCatalog.featured(access) }.onSuccess { games ->
|
||||
if (current == generation) StoresState.post { StoresState.shelves[store] = StoreShelves(trending = games) }
|
||||
}.onFailure { if (current == generation) StoresState.post { StoresState.problems[store] = "Microsoft Store could not be reached. Try again." } }
|
||||
}, "xbox-store").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = XboxCatalog.search(query, access)
|
||||
|
||||
class PackageChoice(val item: CatalogItem, val root: File, val choices: List<org.json.JSONObject>)
|
||||
var packageChoice by androidx.compose.runtime.mutableStateOf<PackageChoice?>(null)
|
||||
private set
|
||||
|
||||
fun dismissPackages() { packageChoice = null }
|
||||
fun selectPackage(context: Context, choice: org.json.JSONObject) {
|
||||
val pending = packageChoice ?: return
|
||||
packageChoice = null
|
||||
val item = pending.item.copy(extra = pending.item.extra + ("contentId" to choice.getString("contentId")))
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, pending.root)
|
||||
enqueue(context.applicationContext, item, pending.root)
|
||||
}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
if (!item.extra["baseId"].isNullOrBlank()) { XboxContent.install(app, item); return }
|
||||
if (!item.extra["contentId"].isNullOrBlank()) { enqueue(app, item, root); return }
|
||||
Thread({
|
||||
try {
|
||||
val plan = Xodus.run(app, listOf("resolve-package", item.id, "--json", "--market", item.extra["market"] ?: Xodus.market()))
|
||||
require(plan.getInt("schema") == 1 && plan.getString("rootProductId") == item.id)
|
||||
val choices = XboxCatalog.objects(plan.getJSONArray("choices")).filter { it.optBoolean("supported") && it.optString("kind") == "Game" }
|
||||
check(choices.isNotEmpty()) { "This offer has no supported PC package." }
|
||||
if (plan.getString("status") == "ready" && choices.size == 1) {
|
||||
val selected = item.copy(extra = item.extra + ("contentId" to choices.single().getString("contentId")))
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, selected, root)
|
||||
enqueue(app, selected, root)
|
||||
} else StoresState.post { packageChoice = PackageChoice(item, root, choices) }
|
||||
} catch (e: Exception) {
|
||||
DownloadQueue.recordFailed(app, DownloadEntry(store, item.id, item.title), e.message ?: "Xbox package could not be resolved.") { install(app, item, root) }
|
||||
}
|
||||
}, "xbox-package").start()
|
||||
}
|
||||
|
||||
fun update(context: Context, game: InstalledStoreGame) {
|
||||
check(!XboxRuntime.isActive(game.sidecar.id)) { "Close this Xbox game before updating it." }
|
||||
val sidecar = game.sidecar
|
||||
val item = CatalogItem(store, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover,
|
||||
owned = sidecar.extra["access"] == "owned", subscription = sidecar.extra["access"] == "gamepass", extra = sidecar.extra)
|
||||
val root = game.folder.parentFile!!.parentFile!!
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root)
|
||||
enqueue(context, item, root)
|
||||
}
|
||||
|
||||
private fun enqueue(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
|
||||
val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes,
|
||||
location = StoreInstallRoot.labelFor(app, folder))
|
||||
DownloadQueue.enqueue(app, entry) { XboxInstaller(app, item, folder) }
|
||||
}
|
||||
|
||||
override fun uninstall(context: Context, game: InstalledStoreGame) {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.Instant
|
||||
|
||||
internal object XboxCatalog {
|
||||
private val productId = Regex("[A-Z0-9]{12}")
|
||||
private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
|
||||
|
||||
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now()): Map<String, String> {
|
||||
require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
|
||||
val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds
|
||||
require(age in -60..299)
|
||||
val array = json.getJSONArray("entitlements")
|
||||
require(array.length() <= 10_000)
|
||||
val rows = LinkedHashMap<String, JSONObject>()
|
||||
val blocked = HashSet<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val row = array.optJSONObject(i) ?: continue
|
||||
val id = row.optString("productId")
|
||||
if (!productId.matches(id)) continue
|
||||
val valid = row.opt("status") is String && row.opt("isTrial") is Boolean && row.opt("special") is Boolean &&
|
||||
row.opt("sharingSource") is String && runCatching { timestamp(row.getString("endDate")) }.isSuccess &&
|
||||
(row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId")))
|
||||
if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row
|
||||
}
|
||||
fun kind(id: String, visited: Set<String> = emptySet()): String? {
|
||||
if (id in blocked || id in visited || visited.size >= 16) return null
|
||||
val row = rows[id] ?: return null
|
||||
if (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") ||
|
||||
row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null
|
||||
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
|
||||
if (parent == null) return if (id in plans) null else "owned"
|
||||
if (parent in blocked) return null
|
||||
if (parent in plans) return "gamepass"
|
||||
return kind(parent, visited + id)
|
||||
}
|
||||
return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
|
||||
}
|
||||
|
||||
private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant()
|
||||
|
||||
fun products(ids: List<String>, access: Map<String, String> = emptyMap(), market: String = Xodus.market()): List<CatalogItem> {
|
||||
return rawProducts(ids, market).mapNotNull { product(it, access) }
|
||||
}
|
||||
|
||||
fun rawProducts(ids: List<String>, market: String = Xodus.market()): List<JSONObject> {
|
||||
return ids.map { it.uppercase() }.filter { productId.matches(it) }.distinct().chunked(25).flatMap { batch ->
|
||||
val body = get("https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=${batch.joinToString(",")}&market=$market&languages=en-us,neutral")
|
||||
objects(body.optJSONArray("Products")).filter { it.optString("ProductId").uppercase() in batch }
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String, access: Map<String, String>): List<CatalogItem> {
|
||||
if (productId.matches(query.uppercase())) return products(listOf(query.uppercase()), access)
|
||||
if (query.length !in 2..100) return emptyList()
|
||||
val q = java.net.URLEncoder.encode(query, "UTF-8")
|
||||
val body = get("https://apps.microsoft.com/api/products/search?query=$q&mediaType=games&age=all&price=all&category=all&subscription=all&gl=${Xodus.market()}&hl=en-us")
|
||||
val ids = objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(60)
|
||||
return products(ids, access)
|
||||
}
|
||||
|
||||
fun featured(access: Map<String, String>): List<CatalogItem> {
|
||||
val body = get("https://apps.microsoft.com/api/Reco/GetComputedProductsList?listName=TopTrending&pgNo=1&noItems=12&filteredCategories=AllProducts&mediaType=Games&gl=${Xodus.market()}&hl=en-us&exp=0")
|
||||
return products(objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(24), access)
|
||||
}
|
||||
|
||||
internal fun product(json: JSONObject, access: Map<String, String>): CatalogItem? {
|
||||
val id = json.optString("ProductId").uppercase()
|
||||
if (!productId.matches(id) || json.optString("ProductType") != "Game") return null
|
||||
val localized = objects(json.optJSONArray("LocalizedProperties")).firstOrNull() ?: return null
|
||||
val title = localized.optString("ProductTitle").takeIf { it.isNotBlank() } ?: return null
|
||||
val packages = objects(json.optJSONArray("DisplaySkuAvailabilities")).flatMap {
|
||||
objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages"))
|
||||
}
|
||||
val pc = packages.filter { pkg -> objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } }
|
||||
if (pc.none { it.optString("PackageFormat").equals("MSIXVC", true) }) return null
|
||||
val images = objects(localized.optJSONArray("Images"))
|
||||
fun image(vararg kinds: String): String? = kinds.firstNotNullOfOrNull { purpose ->
|
||||
images.firstOrNull { it.optString("ImagePurpose") == purpose }?.optString("Uri")?.let { raw ->
|
||||
val url = if (raw.startsWith("//")) "https:$raw" else raw
|
||||
runCatching { java.net.URI(url) }.getOrNull()?.takeIf {
|
||||
it.scheme == "https" && it.host in setOf("store-images.s-microsoft.com", "store-images.microsoft.com") && it.userInfo == null && it.port == -1
|
||||
}?.toString()
|
||||
}
|
||||
}
|
||||
val kind = access[id]
|
||||
return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"),
|
||||
tallImageUrl = image("TitledPoster", "Poster", "BoxArt"),
|
||||
owned = kind == "owned", subscription = kind == "gamepass",
|
||||
storeUrl = "https://www.xbox.com/games/store/$id",
|
||||
publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"),
|
||||
description = localized.optString("ProductDescription"),
|
||||
sizeBytes = pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L,
|
||||
extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty()),
|
||||
)
|
||||
}
|
||||
|
||||
fun objects(array: JSONArray?): List<JSONObject> = if (array == null) emptyList() else
|
||||
(0 until array.length()).mapNotNull { array.optJSONObject(it) }
|
||||
|
||||
private fun get(url: String): JSONObject = JSONObject(StoreNet.get(url) ?: error("Microsoft Store could not be reached. Try again."))
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.*
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
internal object XboxContent {
|
||||
data class Offer(val item: CatalogItem, val installed: Boolean)
|
||||
data class Plan(val base: InstalledStoreGame, val offers: List<Offer>, val error: String? = null)
|
||||
var plan by mutableStateOf<Plan?>(null)
|
||||
private set
|
||||
fun dismiss() { plan = null }
|
||||
|
||||
fun manage(context: Context, base: InstalledStoreGame) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
try {
|
||||
val manifest = XboxManifest.read(base.folder)
|
||||
check(manifest.storeId.matches(Regex("[A-Z0-9]{12}"))) { "The package has no valid Store ID." }
|
||||
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox first.")
|
||||
val market = base.sidecar.extra["market"] ?: Xodus.market()
|
||||
val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market)
|
||||
val products = XboxCatalog.rawProducts(listOf(manifest.storeId) + manifest.related, market)
|
||||
val baseProduct = products.singleOrNull { it.optString("ProductId") == manifest.storeId }
|
||||
val families = packages(baseProduct).map { it.optString("PackageFamilyName") }.filter { it.startsWith(manifest.name + "_") }.toSet()
|
||||
val offers = products.filter { it.optString("ProductId") in manifest.related && it.optString("ProductType") == "Durable" }.mapNotNull { product ->
|
||||
val id = product.getString("ProductId")
|
||||
val access = licenses[id] ?: return@mapNotNull null
|
||||
val packages = packages(product).filter { it.optString("MainPackageFamilyNameForDlc") in families }
|
||||
.distinctBy { it.optString("ContentId") }
|
||||
if (packages.size != 1) return@mapNotNull null
|
||||
val pkg = packages.single()
|
||||
val cid = pkg.optString("ContentId")
|
||||
if (!cid.matches(Regex("[0-9a-fA-F-]{36}"))) return@mapNotNull null
|
||||
val localized = XboxCatalog.objects(product.optJSONArray("LocalizedProperties")).firstOrNull()
|
||||
val item = CatalogItem(Store.XBOX, id, localized?.optString("ProductTitle")?.ifBlank { id } ?: id,
|
||||
imageUrl = base.sidecar.cover, owned = access == "owned", subscription = access == "gamepass",
|
||||
sizeBytes = pkg.optLong("MaxDownloadSizeInBytes"),
|
||||
extra = mapOf("baseId" to base.sidecar.id, "contentId" to cid, "market" to market, "family" to pkg.getString("PackageFamilyName")))
|
||||
Offer(item, File(base.folder, ".xbox-content/$id/.droiddeck-content.json").isFile)
|
||||
}
|
||||
StoresState.post { plan = Plan(base, offers) }
|
||||
} catch (e: Exception) { StoresState.post { plan = Plan(base, emptyList(), e.message ?: "Xbox content could not be loaded.") } }
|
||||
}, "xbox-content").start()
|
||||
}
|
||||
|
||||
private fun packages(product: JSONObject?): List<JSONObject> = XboxCatalog.objects(product?.optJSONArray("DisplaySkuAvailabilities"))
|
||||
.filter { it.optJSONObject("Sku")?.optJSONObject("Properties")?.optBoolean("IsTrial", true) == false }
|
||||
.flatMap { XboxCatalog.objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages")) }
|
||||
.filter { pkg -> pkg.optString("PackageFormat") == "MSIXVC" &&
|
||||
XboxCatalog.objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } }
|
||||
|
||||
private val parents = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
fun busy(base: String): Boolean = StoresState.latestDownloads.any { it.isActive && parents[it.id] == base }
|
||||
|
||||
fun install(context: Context, item: CatalogItem) {
|
||||
plan = null
|
||||
val base = runCatching { base(context, item) }.getOrElse { error ->
|
||||
StoresState.post { StoresState.problems[Store.XBOX] = error.message ?: "The base game is not available." }
|
||||
return
|
||||
}
|
||||
if (XboxRuntime.isActive(base.sidecar.id)) {
|
||||
StoresState.post { StoresState.problems[Store.XBOX] = "Close the Xbox game before changing its content." }
|
||||
return
|
||||
}
|
||||
parents[item.id] = base.sidecar.id
|
||||
val folder = File(base.folder, ".xbox-content/${item.id}")
|
||||
if (folder.canonicalPath != base.folder.canonicalPath + "/.xbox-content/${item.id}") {
|
||||
StoresState.post { StoresState.problems[Store.XBOX] = "The Xbox content folder contains a link outside this game." }
|
||||
return
|
||||
}
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, base.folder.parentFile!!.parentFile!!)
|
||||
com.droiddeck.launcher.stores.download.DownloadQueue.enqueue(context,
|
||||
com.droiddeck.launcher.stores.download.DownloadEntry(Store.XBOX, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes)) {
|
||||
XboxInstaller(context.applicationContext, item, folder)
|
||||
}
|
||||
}
|
||||
|
||||
fun base(context: Context, item: CatalogItem): InstalledStoreGame {
|
||||
require(item.id.matches(Regex("[A-Z0-9]{12}")))
|
||||
val id = item.extra["baseId"] ?: error("Missing Xbox base game")
|
||||
val folder = StoreInstallRoot.gameFolders(context).firstOrNull { StoreGameSidecar.read(it)?.let { s -> s.store == Store.XBOX && s.id == id && s.isInstalled } == true }
|
||||
?: error("The Xbox base game is not installed.")
|
||||
return InstalledStoreGame(StoreGameSidecar.read(folder)!!, folder)
|
||||
}
|
||||
|
||||
fun complete(context: Context, item: CatalogItem, folder: File) {
|
||||
val base = base(context, item)
|
||||
val parent = XboxManifest.read(base.folder)
|
||||
val content = XboxManifest.read(folder)
|
||||
check(item.id in parent.related && content.storeId == item.id && content.dependency == parent.name && content.publisher == parent.publisher && item.extra["family"].orEmpty().startsWith(content.name + "_")) {
|
||||
"The downloaded content does not belong to this Xbox game."
|
||||
}
|
||||
require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}")
|
||||
val marker = File(folder, ".droiddeck-content.json")
|
||||
val temporary = File(folder, marker.name + ".tmp")
|
||||
temporary.writeText(JSONObject().put("productId", item.id).put("parent", base.sidecar.id)
|
||||
.put("contentId", item.extra["contentId"]).put("version", content.version).toString())
|
||||
check(temporary.renameTo(marker))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.content.Context
|
||||
import android.net.LocalServerSocket
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.Closeable
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
|
||||
internal class XboxCredentials(private val app: Context) : Closeable {
|
||||
val name = "droiddeck-xbox-${UUID.randomUUID()}"
|
||||
private val generation = storage(app) { epoch(app) }
|
||||
private val server = LocalServerSocket(name)
|
||||
@Volatile private var closed = false
|
||||
|
||||
init {
|
||||
Thread({
|
||||
while (!closed) {
|
||||
val socket = runCatching { server.accept() }.getOrNull() ?: break
|
||||
socket.use {
|
||||
it.soTimeout = 10_000
|
||||
val reply = runCatching {
|
||||
check(it.peerCredentials.uid == android.os.Process.myUid())
|
||||
storage(app) {
|
||||
check(epoch(app) == generation)
|
||||
answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152)))
|
||||
}
|
||||
}.getOrElse { JSONObject().put("ok", false) }
|
||||
runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) }
|
||||
}
|
||||
}
|
||||
}, "xbox-credentials").start()
|
||||
}
|
||||
|
||||
private fun answer(request: JSONObject): JSONObject {
|
||||
val key = request.getString("key")
|
||||
require(key.isNotEmpty() && key.length <= 512)
|
||||
val record = StoreAccounts.read(app, Store.XBOX)
|
||||
check(!StoreAccounts.isUnavailable(Store.XBOX))
|
||||
val json = record ?: JSONObject()
|
||||
val slots = json.optJSONObject("slots") ?: JSONObject().also { json.put("slots", it) }
|
||||
val result = JSONObject().put("ok", true)
|
||||
when (request.getString("op")) {
|
||||
"get" -> result.put("value", slots.opt(key) ?: JSONObject.NULL)
|
||||
"set" -> {
|
||||
val bytes = request.getJSONArray("value")
|
||||
require(bytes.length() <= 262_144)
|
||||
for (i in 0 until bytes.length()) require(bytes.getInt(i) in 0..255)
|
||||
slots.put(key, bytes)
|
||||
StoreAccounts.write(app, Store.XBOX, json)
|
||||
}
|
||||
"remove" -> { slots.remove(key); StoreAccounts.write(app, Store.XBOX, json) }
|
||||
else -> error("Invalid credential operation")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun close() { closed = true; runCatching { server.close() } }
|
||||
|
||||
companion object {
|
||||
val lock = Any()
|
||||
private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("")
|
||||
fun invalidate(app: Context) = storage(app) {
|
||||
java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString())
|
||||
StoreAccounts.clear(app, Store.XBOX)
|
||||
}
|
||||
fun <T> storage(app: Context, block: () -> T): T = synchronized(lock) {
|
||||
java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file ->
|
||||
file.channel.lock().use { block() }
|
||||
}
|
||||
}
|
||||
fun readLine(input: InputStream, limit: Int): String {
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
while (out.size() <= limit) {
|
||||
val byte = input.read()
|
||||
if (byte == 10) return out.toString("UTF-8")
|
||||
if (byte < 0) throw java.io.EOFException("Xbox backend disconnected")
|
||||
out.write(byte)
|
||||
}
|
||||
error("Xbox backend response exceeded its limit")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.*
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal class XboxInstaller(private val app: Context, private val item: CatalogItem, private val folder: File) : DownloadQueue.DownloadJob {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private val paused = CountDownLatch(1)
|
||||
@Volatile private var child: Xodus.Child? = null
|
||||
@Volatile private var delete = false
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
if (cancelled.get()) return null
|
||||
check(StoreAccounts.signedInAs(app, Store.XBOX) != null) { "Sign in to Xbox before installing." }
|
||||
val market = item.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market()
|
||||
val selected = item.extra["contentId"].orEmpty()
|
||||
require(selected.matches(Regex("[a-fA-F0-9-]{36}"))) { "Select a PC game package before installing." }
|
||||
val content = !item.extra["baseId"].isNullOrBlank()
|
||||
if (content) {
|
||||
check(!XboxRuntime.isActive(item.extra["baseId"].orEmpty())) { "Close the base game before changing Xbox content." }
|
||||
check(item.id in XboxManifest.read(XboxContent.base(app, item).folder).related) { "This content is not related to the base game." }
|
||||
val base = XboxContent.base(app, item)
|
||||
require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}")
|
||||
File(folder, ".droiddeck-content.json").delete()
|
||||
folder.mkdirs()
|
||||
} else {
|
||||
check(!XboxRuntime.isActive(item.id)) { "Close this Xbox game before updating it." }
|
||||
StoreInstalls.markUpdating(folder)
|
||||
StoreInstalls.begin(folder, Store.XBOX, item.id, item.title, item.tallImageUrl, item.imageUrl)
|
||||
}
|
||||
var complete = false
|
||||
var sequence = -1L
|
||||
try {
|
||||
val source = if (content) selected else item.id
|
||||
val selection = if (content) emptyList() else listOf("--selected-content-id", selected)
|
||||
Xodus.start(app, listOf("streaming", source, folder.path, "--market", market,
|
||||
"--progress-json", "--control-stdin") + selection).use { process ->
|
||||
child = process
|
||||
val diagnostics = Xodus.diagnostics(process)
|
||||
if (cancelled.get()) process.process.destroy()
|
||||
process.process.inputStream.bufferedReader().useLines { lines -> lines.forEach { line ->
|
||||
if (line.startsWith("NEXUS_PROGRESS ")) {
|
||||
val progress = JSONObject(line.removePrefix("NEXUS_PROGRESS "))
|
||||
require(progress.getInt("schema") == 1 && progress.getLong("sequence") > sequence)
|
||||
sequence = progress.getLong("sequence")
|
||||
if (progress.optBoolean("paused")) paused.countDown()
|
||||
when (progress.getString("phase")) {
|
||||
"complete" -> complete = true
|
||||
"downloading", "download", "transfer" -> handle.progress(progress.getLong("completedBytes"), progress.optLong("totalBytes"))
|
||||
"finalizing", "installing" -> handle.stage(DownloadStage.INSTALL)
|
||||
else -> handle.stage(DownloadStage.MANIFEST)
|
||||
}
|
||||
}
|
||||
} }
|
||||
check(process.process.waitFor(15, TimeUnit.SECONDS)) { "Xbox installer did not exit." }
|
||||
diagnostics.join(2000)
|
||||
if (cancelled.get()) return null
|
||||
check(process.process.exitValue() == 0 && complete) { "Xbox download did not complete. Resume to retry with fresh licenses." }
|
||||
}
|
||||
if (content) {
|
||||
XboxContent.complete(app, item, folder)
|
||||
return folder.path
|
||||
}
|
||||
val exe = executable(folder)
|
||||
StoreInstalls.complete(app, folder, StoreGameSidecar(Store.XBOX, item.id, item.title, exe,
|
||||
installVersion = XboxManifest.read(folder).version, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl, hero = item.imageUrl,
|
||||
extra = item.extra + mapOf("contentId" to selected, "market" to market)))
|
||||
return folder.path
|
||||
} finally {
|
||||
child = null
|
||||
if (delete && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder)
|
||||
else if (delete) StoreInstalls.discard(app, folder, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
delete = deleteFiles
|
||||
val process = child
|
||||
if (process == null) {
|
||||
if (deleteFiles && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder)
|
||||
else if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
|
||||
return
|
||||
}
|
||||
Thread({
|
||||
runCatching {
|
||||
process.send(JSONObject().put("schema", 1).put("command", "pause"))
|
||||
paused.await(3, TimeUnit.SECONDS)
|
||||
}
|
||||
process.close()
|
||||
}, "xbox-download-stop").start()
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun executable(folder: File): String = XboxManifest.executable(folder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object XboxLibraryCache {
|
||||
data class Snapshot(val games: List<CatalogItem>, val savedAt: Long) {
|
||||
fun needsRefresh(force: Boolean, now: Long = System.currentTimeMillis()): Boolean =
|
||||
force || now - savedAt !in 0 until 15 * 60 * 1000L
|
||||
}
|
||||
|
||||
private fun prefs(app: Context) = app.getSharedPreferences("xbox", Context.MODE_PRIVATE)
|
||||
fun read(app: Context, account: String, market: String): Snapshot? =
|
||||
decode(prefs(app).getString("library", null), account, market)
|
||||
|
||||
fun write(app: Context, account: String, market: String, games: List<CatalogItem>) {
|
||||
check(prefs(app).edit().putString("library", encode(account, market, games)).commit())
|
||||
}
|
||||
|
||||
fun clear(app: Context) { prefs(app).edit().remove("library").commit() }
|
||||
|
||||
internal fun encode(account: String, market: String, games: List<CatalogItem>, now: Long = System.currentTimeMillis()): String =
|
||||
JSONObject().put("schema", 1).put("account", account).put("market", market).put("savedAt", now)
|
||||
.put("games", JSONArray().apply {
|
||||
for (game in games) put(JSONObject().put("id", game.id).put("title", game.title)
|
||||
.put("image", game.imageUrl).put("poster", game.tallImageUrl).put("description", game.description)
|
||||
.put("developer", game.developer).put("publisher", game.publisher).put("size", game.sizeBytes)
|
||||
.put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none"))
|
||||
}).toString()
|
||||
|
||||
internal fun decode(text: String?, account: String, market: String, now: Long = System.currentTimeMillis()): Snapshot? = runCatching {
|
||||
require(text != null && text.length <= 8_000_000)
|
||||
val json = JSONObject(text)
|
||||
require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
|
||||
val savedAt = json.getLong("savedAt")
|
||||
require(savedAt > 0 && savedAt <= now + 60_000)
|
||||
val rows = json.getJSONArray("games")
|
||||
require(rows.length() <= 10_000)
|
||||
val games = (0 until rows.length()).map { index ->
|
||||
val row = rows.getJSONObject(index)
|
||||
val id = row.getString("id")
|
||||
val title = row.getString("title")
|
||||
val access = row.getString("access")
|
||||
require(id.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && access in setOf("owned", "gamepass"))
|
||||
CatalogItem(Store.XBOX, id, title, row.optString("image").takeIf { it.isNotEmpty() },
|
||||
tallImageUrl = row.optString("poster").takeIf { it.isNotEmpty() },
|
||||
description = row.optString("description"), developer = row.optString("developer"), publisher = row.optString("publisher"),
|
||||
sizeBytes = row.optLong("size"), owned = access == "owned", subscription = access == "gamepass",
|
||||
storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access))
|
||||
}
|
||||
require(games.map { it.id }.distinct().size == games.size)
|
||||
Snapshot(games, savedAt)
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class XboxLoginActivity : ComponentActivity() {
|
||||
private lateinit var host: LinearLayout
|
||||
private lateinit var status: TextView
|
||||
private var view: WebView? = null
|
||||
@Volatile private var child: Xodus.Child? = null
|
||||
@Volatile private var session = 0L
|
||||
@Volatile private var closed = false
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
host = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(Color.BLACK) }
|
||||
status = TextView(this).apply { setTextColor(Color.WHITE); text = "Preparing Microsoft sign-in…"; setPadding(24, 16, 24, 16) }
|
||||
host.addView(status)
|
||||
host.addView(Button(this).apply { text = getString(com.droiddeck.launcher.R.string.stores_login_close); setOnClickListener { finish() } })
|
||||
setContentView(host)
|
||||
if (android.os.Build.VERSION.SDK_INT < 28 || !WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) ||
|
||||
!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||
status.text = "Update Android System WebView to sign in to Xbox."
|
||||
return
|
||||
}
|
||||
CookieManager.getInstance().removeAllCookies { begin() }
|
||||
}
|
||||
|
||||
private fun begin() {
|
||||
if (closed) return
|
||||
Thread({
|
||||
if (closed) return@Thread
|
||||
try {
|
||||
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process ->
|
||||
child = process
|
||||
if (closed) return@use
|
||||
Xodus.diagnostics(process)
|
||||
val timer = Thread({
|
||||
runCatching { if (!process.process.waitFor(10, TimeUnit.MINUTES)) process.process.destroy() }
|
||||
}, "xbox-login-timeout").apply { start() }
|
||||
var ready = false
|
||||
var latest = 0L
|
||||
val input = process.process.inputStream.buffered()
|
||||
while (!closed) {
|
||||
val line = try { XboxCredentials.readLine(input, 131_072) } catch (e: java.io.EOFException) { break }
|
||||
if (!line.startsWith("NEXUS_AUTH_V1 ")) continue
|
||||
val frame = JSONObject(line.removePrefix("NEXUS_AUTH_V1 "))
|
||||
when (frame.getString("type")) {
|
||||
"ready" -> {
|
||||
check(!ready && frame.getInt("version") == 1)
|
||||
ready = true
|
||||
process.send(JSONObject().put("type", "hello").put("version", 1))
|
||||
}
|
||||
"open" -> {
|
||||
val next = frame.getLong("session")
|
||||
check(ready && next > latest && next <= 32 && trusted(frame.getString("url")))
|
||||
latest = next; session = next
|
||||
runOnUiThread { if (!closed) open(frame) }
|
||||
}
|
||||
"close" -> {
|
||||
check(frame.getLong("session") == session)
|
||||
session = 0L
|
||||
runOnUiThread { disposeView(); status.text = "Completing Microsoft sign-in…" }
|
||||
}
|
||||
"context" -> {
|
||||
check(frame.getLong("session") == session)
|
||||
val context = frame.getString("context")
|
||||
check(context.length <= 4096)
|
||||
val message = JSONObject().put("type", "callback").put("value", JSONObject()
|
||||
.put("name", "CloudExperienceHost.getContext")
|
||||
.put("args", org.json.JSONArray(listOf("CloudExperienceHost", "TokenBroker", "TokenBroker", "{\"PrivatePropertyBag\":1,\"PasswordlessConnect\":1,\"PreferAssociate\":1,\"ChromelessUI\":0}")))
|
||||
.put("context", context))
|
||||
val active = session
|
||||
runOnUiThread { if (active == session && trusted(view?.url.orEmpty())) view?.evaluateJavascript(
|
||||
"globalThis['CloudExperienceHost.Bridge.dispatchMessage'](${JSONObject.quote(message.toString())})", null) }
|
||||
}
|
||||
else -> error("Unsupported Xbox sign-in response")
|
||||
}
|
||||
}
|
||||
check(process.process.waitFor(5, TimeUnit.SECONDS) && process.process.exitValue() == 0 && ready && latest > 0 && session == 0L)
|
||||
timer.interrupt()
|
||||
}
|
||||
child = null
|
||||
if (closed) return@Thread
|
||||
val account = Xodus.run(applicationContext, listOf("account", "--json"))
|
||||
check(account.optBoolean("stored"))
|
||||
XboxCredentials.storage(this) {
|
||||
val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing")
|
||||
record.put("username", account.getString("username"))
|
||||
StoreAccounts.write(this, Store.XBOX, record)
|
||||
}
|
||||
runOnUiThread { finish() }
|
||||
} catch (e: Exception) {
|
||||
child = null
|
||||
if (!closed) runOnUiThread { disposeView(); status.text = "Microsoft sign-in did not complete. Close this window and try again." }
|
||||
}
|
||||
}, "xbox-login").start()
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun open(frame: JSONObject) {
|
||||
disposeView()
|
||||
val active = frame.getLong("session")
|
||||
val web = WebView(this)
|
||||
view = web
|
||||
web.settings.apply {
|
||||
javaScriptEnabled = true; domStorageEnabled = true
|
||||
allowFileAccess = false; allowContentAccess = false
|
||||
mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; MSAppHost/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.26100"
|
||||
}
|
||||
WebViewCompat.addWebMessageListener(web, "droiddeckXbox", origins) { _, message, origin, mainFrame, _ ->
|
||||
val payload = message.data
|
||||
if (mainFrame && trusted(origin.toString()) && trusted(web.url.orEmpty()) && active == session && payload != null && payload.toByteArray().size <= 65_536) {
|
||||
runCatching { child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload)) }
|
||||
}
|
||||
}
|
||||
WebViewCompat.addDocumentStartJavaScript(web,
|
||||
"if(window===window.top){Object.defineProperty(globalThis.external,'notify',{value:p=>{if(typeof p==='string')droiddeckXbox.postMessage(p)},configurable:false,writable:false})}", origins)
|
||||
web.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = !trusted(request.url.toString())
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (!trusted(url)) { finish(); return }
|
||||
status.text = "Microsoft sign-in"
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.host == "login.live.com" && uri.path == "/ppsecure/post.srf") {
|
||||
view.evaluateJavascript("JSON.stringify(globalThis.ServerData ?? null)") { raw ->
|
||||
val payload = runCatching { org.json.JSONArray("[$raw]").getString(0) }.getOrNull()
|
||||
if (active == session && payload != null && payload != "null" && payload.length <= 65_536) runCatching {
|
||||
child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
host.addView(web, LinearLayout.LayoutParams(-1, 0, 1f))
|
||||
val headers = frame.getJSONObject("headers")
|
||||
val values = headers.keys().asSequence().associateWith { key ->
|
||||
require(key in allowedHeaders)
|
||||
headers.getString(key).also { value -> require(value.length <= 4096 && value.all { it in ' '..'~' }) }
|
||||
}
|
||||
web.loadUrl(frame.getString("url"), values)
|
||||
}
|
||||
|
||||
private fun disposeView() { view?.let { host.removeView(it); it.stopLoading(); it.destroy() }; view = null }
|
||||
override fun onDestroy() {
|
||||
closed = true
|
||||
val current = child; child = null
|
||||
Thread({ runCatching { current?.close() } }, "xbox-login-close").start()
|
||||
disposeView()
|
||||
CookieManager.getInstance().removeAllCookies(null)
|
||||
android.webkit.WebStorage.getInstance().deleteAllData()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val hosts = setOf("login.live.com", "account.live.com", "login.microsoftonline.com", "login.microsoft.com", "account.microsoft.com", "sisu.xboxlive.com")
|
||||
private val origins = hosts.map { "https://$it" }.toSet()
|
||||
private val allowedHeaders = setOf("cxh-capabilities", "cxh-correlationid", "cxh-msabinaryversion", "cxh-identityclientbinaryversion", "cxh-osversioninfo", "cxh-platform", "cxh-protocol", "cxh-source", "hostapp")
|
||||
internal fun trusted(value: String): Boolean = runCatching {
|
||||
val url = java.net.URI(value)
|
||||
url.scheme == "https" && url.host in hosts && url.port == -1 && url.userInfo == null
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import java.io.File
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
internal object XboxManifest {
|
||||
data class Info(val storeId: String, val name: String, val publisher: String, val version: String,
|
||||
val dependency: String, val related: List<String>, val executables: List<String>)
|
||||
|
||||
fun read(folder: File): Info {
|
||||
val manifests = folder.listFiles().orEmpty().filter { it.name.equals("MicrosoftGame.config", true) && it.isFile }
|
||||
require(manifests.size == 1) { "Missing or ambiguous MicrosoftGame.config." }
|
||||
val manifest = manifests.single()
|
||||
require(manifest.length() <= 1_048_576)
|
||||
val parser = android.util.Xml.newPullParser().apply { setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false) }
|
||||
val identities = ArrayList<List<String>>()
|
||||
val storeIds = ArrayList<String>()
|
||||
val dependencies = ArrayList<String>()
|
||||
val related = ArrayList<String>()
|
||||
val entries = ArrayList<String>()
|
||||
fun attribute(name: String) = parser.getAttributeValue(null, name).orEmpty()
|
||||
var root = false
|
||||
var elements = 0
|
||||
manifest.inputStream().use { input ->
|
||||
parser.setInput(input, null)
|
||||
while (true) {
|
||||
when (parser.nextToken()) {
|
||||
XmlPullParser.END_DOCUMENT -> break
|
||||
XmlPullParser.DOCDECL -> error("Xbox game manifests cannot contain a DTD.")
|
||||
XmlPullParser.START_TAG -> {
|
||||
require(++elements <= 10_000 && parser.depth <= 64) { "Xbox game manifest is too complex." }
|
||||
if (parser.depth == 1) { require(!root && parser.name == "Game"); root = true }
|
||||
when (parser.name) {
|
||||
"Identity" -> identities.add(listOf(attribute("Name"), attribute("Publisher"), attribute("Version")))
|
||||
"StoreId" -> storeIds.add(parser.nextText().trim())
|
||||
"MainPackageDependency" -> dependencies.add(attribute("Name"))
|
||||
"RelatedProduct" -> related.add(parser.nextText().trim())
|
||||
"Executable" -> {
|
||||
val path = attribute("Name").replace('\\', '/')
|
||||
if (path.endsWith(".exe", true) && attribute("TargetDeviceFamily") in listOf("", "PC", "Windows.Desktop") &&
|
||||
!attribute("IsDevOnly").equals("true", true)) entries.add(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require(root && identities.size <= 1 && storeIds.size <= 1 && dependencies.size <= 1) { "Invalid Xbox game manifest." }
|
||||
val ids = related.distinct()
|
||||
require(ids.size <= 128 && ids.all { it.matches(Regex("[A-Z0-9]{12}")) })
|
||||
val identity = identities.singleOrNull()
|
||||
return Info(storeIds.singleOrNull().orEmpty(), identity?.get(0).orEmpty(), identity?.get(1).orEmpty(), identity?.get(2).orEmpty(),
|
||||
dependencies.singleOrNull().orEmpty(), ids, entries)
|
||||
}
|
||||
|
||||
fun executable(folder: File): String {
|
||||
for (path in read(folder).executables) {
|
||||
require(!path.startsWith('/') && !path.contains(':') && path.split('/').none { it == ".." || it.isEmpty() })
|
||||
var file = folder
|
||||
for (part in path.split('/')) {
|
||||
val matches = file.listFiles().orEmpty().filter { it.name.equals(part, true) }
|
||||
require(matches.size == 1) { "Missing or ambiguous Xbox launch path." }
|
||||
file = matches.single()
|
||||
}
|
||||
require(file.canonicalPath.startsWith(folder.canonicalPath + File.separator))
|
||||
if (file.isFile) return file.relativeTo(folder).invariantSeparatorsPath
|
||||
}
|
||||
error("The Xbox package has no supported PC launch executable.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.stores.*
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
internal object XboxRuntime {
|
||||
private var child: Xodus.Child? = null
|
||||
private var owner: String? = null
|
||||
private val activeGames = HashMap<String, Int>()
|
||||
@Synchronized fun isActive(id: String): Boolean = activeGames.getOrDefault(id, 0) > 0
|
||||
@Synchronized fun released(id: String) {
|
||||
val count = activeGames.getOrDefault(id, 0)
|
||||
if (count <= 1) activeGames.remove(id) else activeGames[id] = count - 1
|
||||
}
|
||||
|
||||
@Synchronized fun prepare(app: Context, id: String): JSONObject {
|
||||
check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" }
|
||||
check(SessionState.running) { "No running DroidDeck session" }
|
||||
check(StoreGameSidecar.find(app, Store.XBOX, id)?.isInstalled == true) { "Xbox game is not installed" }
|
||||
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
|
||||
val socket = File(app.filesDir, "xbox-runtime/xodus.sock")
|
||||
if (child?.process?.isAlive != true || owner != account) {
|
||||
stop()
|
||||
socket.delete()
|
||||
val service = Xodus.start(app, emptyList(), service = true)
|
||||
child = service; owner = account
|
||||
Xodus.diagnostics(service)
|
||||
Thread({ runCatching { service.process.inputStream.use { val buffer = ByteArray(8192); while (it.read(buffer) >= 0) {} } } }, "xbox-service-output").start()
|
||||
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
|
||||
while (!socket.exists() && service.process.isAlive && SessionState.running && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
|
||||
if (!socket.exists() || !service.process.isAlive || !SessionState.running) {
|
||||
stop(); error("Xbox account service did not become ready. Check your sign-in and network connection.")
|
||||
}
|
||||
}
|
||||
activeGames[id] = activeGames.getOrDefault(id, 0) + 1
|
||||
return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket)
|
||||
}
|
||||
|
||||
@Synchronized fun stop() {
|
||||
val process = child; child = null; owner = null; activeGames.clear()
|
||||
runCatching { process?.close() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.droiddeck.launcher.stores.xbox
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal object Xodus {
|
||||
const val DIR = "/usr/local/lib/droiddeck-xbox"
|
||||
fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
|
||||
|
||||
class Child(val process: Process, private val credentials: XboxCredentials) : Closeable {
|
||||
val credentialSocket: String get() = credentials.name
|
||||
@Synchronized fun send(frame: JSONObject) {
|
||||
process.outputStream.write((frame.toString() + "\n").toByteArray())
|
||||
process.outputStream.flush()
|
||||
}
|
||||
override fun close() {
|
||||
process.destroy()
|
||||
if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
|
||||
credentials.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized private fun stage(app: Context) {
|
||||
val root = File(LinuxRuntime.rootDir(app), DIR.removePrefix("/"))
|
||||
val packaged = app.assets.open("linuxfs$DIR/version").bufferedReader().use { it.readText() }
|
||||
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return
|
||||
fun copy(relative: String) {
|
||||
val entries = app.assets.list("linuxfs$DIR/$relative").orEmpty()
|
||||
if (entries.isNotEmpty()) { entries.forEach { copy(if (relative.isEmpty()) it else "$relative/$it") }; return }
|
||||
if (relative == "version") return
|
||||
val file = File(root, relative)
|
||||
file.parentFile?.mkdirs()
|
||||
val staged = File(file.path + ".staged")
|
||||
app.assets.open("linuxfs$DIR/$relative").use { input -> staged.outputStream().use { input.copyTo(it) } }
|
||||
staged.setExecutable(true, false)
|
||||
check(staged.renameTo(file))
|
||||
}
|
||||
copy("")
|
||||
File(root, "version").writeText(packaged)
|
||||
}
|
||||
|
||||
fun start(app: Context, args: List<String>, service: Boolean = false): Child {
|
||||
check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." }
|
||||
stage(app)
|
||||
val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}"
|
||||
check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." }
|
||||
val credentials = XboxCredentials(app)
|
||||
try {
|
||||
val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() }
|
||||
val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf(
|
||||
"/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off",
|
||||
"XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary,
|
||||
) + args)
|
||||
val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app))
|
||||
builder.environment().apply {
|
||||
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
|
||||
put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
|
||||
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path)
|
||||
if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
|
||||
LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
return Child(builder.start(), credentials)
|
||||
} catch (e: Exception) { credentials.close(); throw e }
|
||||
}
|
||||
|
||||
fun run(app: Context, args: List<String>, timeout: Long = 90): JSONObject {
|
||||
start(app, args).use { child ->
|
||||
val output = java.io.ByteArrayOutputStream()
|
||||
var readError: Throwable? = null
|
||||
val reader = Thread({
|
||||
try {
|
||||
val buffer = ByteArray(8192)
|
||||
child.process.inputStream.use { input ->
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
check(output.size() + count <= 8_000_000)
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) { readError = t; child.process.destroy() }
|
||||
}, "xbox-result").apply { start() }
|
||||
val diagnostic = diagnostics(child)
|
||||
check(child.process.waitFor(timeout, TimeUnit.SECONDS)) { "Xbox request timed out. Try again." }
|
||||
reader.join(3000); diagnostic.join(3000)
|
||||
check(readError == null && !reader.isAlive) { "Xbox returned an invalid response." }
|
||||
check(child.process.exitValue() == 0) { "Xbox request failed. Check your account and network connection." }
|
||||
return JSONObject(output.toString("UTF-8"))
|
||||
}
|
||||
}
|
||||
|
||||
fun diagnostics(child: Child): Thread = Thread({
|
||||
runCatching {
|
||||
child.process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line ->
|
||||
if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\.")))
|
||||
com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $line")
|
||||
} }
|
||||
}
|
||||
}, "xbox-diagnostics").apply { start() }
|
||||
}
|
||||
@@ -27,7 +27,7 @@ internal fun FrameGenMenu(
|
||||
onPick(m); host.open = null
|
||||
}
|
||||
item(FrameGen.Mode.OFF, focus = firstItemFocus)
|
||||
for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m))
|
||||
for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_DDFG, m))
|
||||
val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } +
|
||||
FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) }
|
||||
lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) }
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.itch.ItchApi
|
||||
import com.droiddeck.launcher.stores.itch.ItchAuth
|
||||
import com.droiddeck.launcher.stores.itch.ItchLibrary
|
||||
|
||||
private fun copiedKey(ctx: Context): String? {
|
||||
val clipboard = ctx.getSystemService(ClipboardManager::class.java)
|
||||
val text = runCatching { clipboard?.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull() ?: return null
|
||||
return ItchAuth.candidates(text).firstOrNull()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItchKeyDialog(onDismiss: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
var key by remember { mutableStateOf(copiedKey(ctx).orEmpty()) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var rejected by remember { mutableStateOf(false) }
|
||||
|
||||
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME && key.isBlank()) {
|
||||
copiedKey(ctx)?.let { key = it }
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val raw = key.trim()
|
||||
if (raw.isEmpty() || busy) return
|
||||
busy = true
|
||||
rejected = false
|
||||
Thread({
|
||||
val user = ItchAuth.verify(raw)
|
||||
if (user == null) {
|
||||
StoresState.post { busy = false; rejected = true }
|
||||
return@Thread
|
||||
}
|
||||
ItchAuth.save(ctx, raw, user)
|
||||
StoresState.post { busy = false; onDismiss() }
|
||||
StoresState.refresh(ctx)
|
||||
ItchLibrary.sync(ctx, true)
|
||||
}, "itch-key").start()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onDismiss() },
|
||||
title = { Text(Store.ITCH.label) },
|
||||
text = {
|
||||
Column {
|
||||
AdbTextField(
|
||||
value = key,
|
||||
onValueChange = { key = it; rejected = false },
|
||||
label = stringResource(R.string.stores_itch_key),
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Done,
|
||||
focusOnShow = true,
|
||||
onDone = { submit() },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
TextButton(onClick = {
|
||||
ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)))
|
||||
}) { Text(stringResource(R.string.stores_itch_get_key)) }
|
||||
}
|
||||
if (rejected) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.stores_itch_key_rejected),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = key.isNotBlank() && !busy, onClick = { submit() }) {
|
||||
Text(stringResource(R.string.game_env_save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !busy, onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,8 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
|
||||
Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8))
|
||||
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
|
||||
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
|
||||
Store.XBOX.id -> SourceColours(Color(0xFF17381B), Color(0xFFC6EDC2), Color(0xFF70C850))
|
||||
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
@@ -40,6 +42,8 @@ internal fun sourceLabel(source: String): String = when (source) {
|
||||
Store.GOG.id -> Store.GOG.label
|
||||
Store.EPIC.id -> Store.EPIC.label
|
||||
Store.AMAZON.id -> Store.AMAZON.label
|
||||
Store.XBOX.id -> Store.XBOX.label
|
||||
Store.ITCH.id -> Store.ITCH.label
|
||||
else -> stringResource(R.string.source_added)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int): Int? {
|
||||
// `split` is the size of a leading section that starts its own rows; 0 means one continuous grid.
|
||||
internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int, split: Int = 0): Int? {
|
||||
if (index !in 0 until count || columns < 1 || direction !in listOf(-1, 1)) return null
|
||||
return (index + columns * direction).takeIf { it in 0 until count }
|
||||
if (split !in 1 until count) return (index + columns * direction).takeIf { it in 0 until count }
|
||||
val inFirst = index < split
|
||||
val start = if (inFirst) 0 else split
|
||||
val end = if (inFirst) split else count
|
||||
val local = index - start
|
||||
val within = start + local + columns * direction
|
||||
if (within in start until end) return within
|
||||
val column = local % columns
|
||||
return when {
|
||||
inFirst && direction > 0 -> split + minOf(column, count - split - 1)
|
||||
!inFirst && direction < 0 && local < columns -> ((split - 1) / columns) * columns + column.coerceAtMost((split - 1) % columns)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -95,7 +97,8 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
|
||||
download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location
|
||||
failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location
|
||||
item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
|
||||
item?.subscription == true -> "PC Game Pass"
|
||||
item?.accessible == true -> stringResource(R.string.stores_eyebrow_library)
|
||||
else -> stringResource(R.string.stores_eyebrow_store)
|
||||
}
|
||||
val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: ""
|
||||
@@ -115,10 +118,21 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
Actions {
|
||||
when {
|
||||
installed != null -> {
|
||||
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
|
||||
val gone = StoresState.uninstalling["${store.id}:$id"]
|
||||
PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id)), main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) {
|
||||
launchStoreGame(ctx, store, id, s, a)
|
||||
}
|
||||
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
|
||||
if (store == Store.XBOX) {
|
||||
SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) {
|
||||
com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed)
|
||||
}
|
||||
SecondaryButton("Manage content", enabled = download == null) {
|
||||
com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed)
|
||||
}
|
||||
}
|
||||
if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {}
|
||||
else UninstallButton(title, installed, enabled = download == null, modifier = leadOn) {
|
||||
else UninstallButton(title, installed, enabled = download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id))) {
|
||||
StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() }
|
||||
}
|
||||
}
|
||||
@@ -138,11 +152,11 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key)
|
||||
}
|
||||
}
|
||||
item?.owned == true && StoresState.isUnfinished(item) -> {
|
||||
item?.accessible == true && StoresState.isUnfinished(item) -> {
|
||||
InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
|
||||
}
|
||||
item?.owned == true -> InstallButton(
|
||||
item?.accessible == true -> InstallButton(
|
||||
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
|
||||
leadOn,
|
||||
) { StoresState.requestInstall(ctx, item) }
|
||||
@@ -164,10 +178,10 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
}
|
||||
val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty()
|
||||
.takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty()
|
||||
val achievements = remember(store, id, item?.owned, installed) {
|
||||
val achievements = remember(store, id, item?.accessible, installed) {
|
||||
when {
|
||||
StoreAchievements.forSource(store.id) == null -> null
|
||||
item?.owned == true -> StoreAchievements.Game.of(item)
|
||||
item?.accessible == true -> StoreAchievements.Game.of(item)
|
||||
installed != null -> StoreAchievements.Game.of(installed.sidecar)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -182,10 +182,7 @@ private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
|
||||
private fun InterruptedRow(item: com.droiddeck.launcher.stores.CatalogItem) {
|
||||
val ctx = LocalContext.current
|
||||
ArtStrip(item.imageUrl ?: item.tallImageUrl, item.title) {
|
||||
Text(item.title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(item.store.id, small = true)
|
||||
Text(stringResource(R.string.stores_dl_interrupted), fontSize = 12.sp, color = Color(0xFFFFC24D), maxLines = 1)
|
||||
Spacer(Modifier.weight(1f))
|
||||
StripLabel(item.title, item.store.id, stringResource(R.string.stores_dl_interrupted), Color(0xFFFFC24D))
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { StoresState.requestInstall(ctx, item) }
|
||||
CancelAction { StoresState.clearUnfinished(ctx, item) }
|
||||
}
|
||||
@@ -207,14 +204,20 @@ private fun ArtStrip(cover: String?, name: String, content: @Composable androidx
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.StripLabel(title: String, store: String, status: String, statusColor: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(store, small = true)
|
||||
Text(status, fontSize = 12.sp, color = statusColor, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, bloom: Boolean = false) {
|
||||
val ctx = LocalContext.current
|
||||
ArtStrip(d.cover, d.name) {
|
||||
Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(d.store.id, small = true)
|
||||
Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 12.sp, color = Color.White.copy(alpha = 0.72f), maxLines = 1)
|
||||
Spacer(Modifier.weight(1f))
|
||||
StripLabel(d.name, d.store.id, stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), Color.White.copy(alpha = 0.72f))
|
||||
if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) {
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) }
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Sort
|
||||
import androidx.compose.material.icons.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.ViewModule
|
||||
import com.droiddeck.launcher.stores.sortCatalogItems
|
||||
import com.droiddeck.launcher.stores.retainCatalogOrder
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -148,18 +149,47 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
private const val DOWNLOADS = "downloads"
|
||||
|
||||
private val TABS = listOf("store", "library", "installed")
|
||||
private val TABS = listOf("library", "store")
|
||||
|
||||
internal fun tabsFor(store: Store?): List<String> = if (store == Store.AMAZON) TABS.drop(1) else TABS
|
||||
internal fun tabsFor(store: Store?): List<String> = if (store == Store.AMAZON) TABS.take(1) else TABS
|
||||
|
||||
internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library"
|
||||
|
||||
private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean)
|
||||
private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean, val accountsLoaded: Boolean)
|
||||
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val ctx = LocalContext.current
|
||||
com.droiddeck.launcher.stores.xbox.XboxContent.plan?.let { content ->
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() },
|
||||
title = { Text("Xbox content") },
|
||||
text = { Column {
|
||||
if (content.error != null) Text(content.error)
|
||||
else if (content.offers.isEmpty()) Text("No licensed PC content packages are available for this game.")
|
||||
content.offers.forEach { offer ->
|
||||
androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.install(ctx, offer.item) }) {
|
||||
Text(offer.item.title + if (offer.installed) " · Update / verify" else " · Install")
|
||||
}
|
||||
}
|
||||
} },
|
||||
confirmButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }) { Text(stringResource(android.R.string.ok)) } },
|
||||
)
|
||||
}
|
||||
com.droiddeck.launcher.stores.xbox.XboxBackend.packageChoice?.let { pending ->
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() },
|
||||
title = { Text("Choose PC content") },
|
||||
text = { Column { pending.choices.forEach { choice ->
|
||||
androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.selectPackage(ctx, choice) }) {
|
||||
Text(choice.getString("title") + " (" + choice.getString("kind") + ")")
|
||||
}
|
||||
} } },
|
||||
confirmButton = {},
|
||||
dismissButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val narrow = LocalNarrowPane.current
|
||||
val padH = if (narrow) 14.dp else 22.dp
|
||||
@@ -174,6 +204,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
val pane = PaneKey(
|
||||
chip, if (store == null) "" else shownTab,
|
||||
store == null || (StoresState.isSignedIn(store) && StoresState.expired[store] != true),
|
||||
store == null || StoresState.accountsLoaded,
|
||||
)
|
||||
val grids = remember { HashMap<PaneKey, LazyGridState>() }
|
||||
val grid = grids.getOrPut(pane) { LazyGridState() }
|
||||
@@ -214,41 +245,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
}
|
||||
}
|
||||
val pal = LocalPalette.current
|
||||
fun openModes(st: Store, at: Rect?) {
|
||||
if (chip != st.id) { pickChip(st.id); return }
|
||||
if (at == null || !StoresState.isSignedIn(st) || StoresState.expired[st] == true) { move("first"); return }
|
||||
val modes = tabsFor(st)
|
||||
val current = if (chip == st.id) shownTab else null
|
||||
var picked = false
|
||||
StoresMotion.ask(StepAsk(
|
||||
anchor = at, side = StepSide.Right, accent = storePalette(pal, st).signal, items = modes.size,
|
||||
onDismiss = { move(if (picked) "first" else "item:storechip:${st.id}") },
|
||||
handle = { ChipLabel(st, MaterialTheme.colorScheme.onBackground) },
|
||||
) {
|
||||
StoreTheme(st) {
|
||||
val library = StoresState.library[st].orEmpty()
|
||||
val installedCount = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(st, StoresState.installed, library).size }
|
||||
StepList {
|
||||
modes.forEachIndexed { i, m ->
|
||||
val label = when (m) {
|
||||
"store" -> stringResource(R.string.stores_tab_store)
|
||||
"installed" -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedCount)
|
||||
else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
|
||||
}
|
||||
val focusHere = if (current != null) m == current else i == 0
|
||||
StepChoice(
|
||||
label, selected = m == current, enabled = open,
|
||||
modifier = Modifier.fillMaxWidth().stepItem(i).then(if (focusHere) Modifier.focusRequester(first) else Modifier),
|
||||
) {
|
||||
picked = true
|
||||
if (chip != st.id) { chip = st.id; openGame = null; query = "" }
|
||||
tab = m
|
||||
StoresMotion.fold()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
fun cycleStore(step: Int) {
|
||||
val ids = Store.entries.map { it.id }
|
||||
val at = ids.indexOf(chip)
|
||||
pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size])
|
||||
}
|
||||
fun openModes(st: Store) {
|
||||
if (chip != st.id) pickChip(st.id) else move("first")
|
||||
}
|
||||
BackHandler(enabled = openGame != null) { closeGame() }
|
||||
val scroll = rememberScrollState()
|
||||
@@ -326,13 +329,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
|
||||
.bumpers(
|
||||
onPrevious = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + t.size - 1) % t.size]) } },
|
||||
onNext = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + 1) % t.size]) } },
|
||||
onPrevious = { if (openGame == null) cycleStore(-1) },
|
||||
onNext = { if (openGame == null) cycleStore(1) },
|
||||
),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
StoreTheme(Store.byId(focusedChip ?: chip)) {
|
||||
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
|
||||
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, _ -> openModes(st) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
|
||||
onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) }
|
||||
}
|
||||
}
|
||||
@@ -389,8 +392,11 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
when {
|
||||
k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) }
|
||||
st == null -> {}
|
||||
!k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp))
|
||||
}
|
||||
!k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) }
|
||||
else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) })
|
||||
else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,21 +574,23 @@ private fun StoreChips(
|
||||
Box(
|
||||
Modifier.size(12.dp)
|
||||
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
|
||||
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot),
|
||||
) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) }
|
||||
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot)
|
||||
.background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
|
||||
)
|
||||
},
|
||||
description = store.label + if (signedIn) "" else " " + stringResource(R.string.stores_chip_signed_out),
|
||||
description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out),
|
||||
onFocused = onFocusChip,
|
||||
) { onStore(store, rootBounds[store.id]) }
|
||||
}
|
||||
StoreChip(
|
||||
label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
|
||||
modifier = Modifier.weight(1f).onGloballyPositioned {
|
||||
label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
bounds[DOWNLOADS] = it.boundsInParent()
|
||||
StoresMotion.downloadsChip = it.boundsInRoot()
|
||||
},
|
||||
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
|
||||
trail = if (active > 0) { { CountPill(active) } } else null,
|
||||
description = stringResource(R.string.stores_tab_downloads),
|
||||
onFocused = onFocusChip,
|
||||
) { onDownloads() }
|
||||
var cogAt by remember { mutableStateOf<Rect?>(null) }
|
||||
@@ -596,7 +604,7 @@ private fun StoreChips(
|
||||
|
||||
@Composable
|
||||
private fun StoreChip(
|
||||
label: String, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
trail: (@Composable () -> Unit)? = null, description: String? = null,
|
||||
onFocused: (String?) -> Unit = {}, onClick: () -> Unit,
|
||||
) {
|
||||
@@ -634,7 +642,7 @@ private fun StoreChip(
|
||||
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) {
|
||||
Box(Modifier.pulseRing(picked, mark), contentAlignment = Alignment.Center) { lead() }
|
||||
}
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (label != null) Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
trail?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -724,11 +732,16 @@ private fun SignInButton(store: Store, large: Boolean = false) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
|
||||
var askKey by remember { mutableStateOf(false) }
|
||||
if (askKey) ItchKeyDialog { askKey = false }
|
||||
Box(Modifier.onGloballyPositioned { placed[0] = it }) {
|
||||
PrimaryButton(stringResource(R.string.stores_signin), main = true, large = large, icon = if (large) Icons.AutoMirrored.Filled.Login else null) {
|
||||
val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
|
||||
else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
|
||||
if (store == Store.ITCH) askKey = true
|
||||
else {
|
||||
val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
|
||||
else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,20 +759,22 @@ internal fun StoreLogo(store: Store, size: Dp) {
|
||||
private fun Storefront(
|
||||
store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions,
|
||||
grid: LazyGridState,
|
||||
onQuery: (String) -> Unit, onOpen: (String) -> Unit,
|
||||
onQuery: (String) -> Unit, onOpen: (String) -> Unit, onTab: (String) -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val library = StoresState.library[store] ?: emptyList()
|
||||
val shelves = StoresState.shelves[store]
|
||||
val found = StoresState.found[store] ?: emptyList()
|
||||
val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) }
|
||||
val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } }
|
||||
val q = query.trim().lowercase()
|
||||
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q)
|
||||
fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned
|
||||
val foundKeys = remember(found) { found.map { it.key }.toSet() }
|
||||
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys
|
||||
fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.accessible
|
||||
fun shelf(l: List<CatalogItem>?) = l?.filter(::shown)
|
||||
val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
|
||||
val installedOnly = tab == "installed"
|
||||
val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
|
||||
LaunchedEffect(store, q) { StoresState.search(ctx, store, q) }
|
||||
val loaded = StoresState.library.containsKey(store)
|
||||
val showGrid = tab != "store" || q.isNotEmpty()
|
||||
val sort = s.storesSort
|
||||
@@ -773,63 +788,45 @@ private fun Storefront(
|
||||
val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L }
|
||||
lookup
|
||||
}
|
||||
val isSizeSort = (tab == "library" || tab == "installed") && (sort == "sizeAsc" || sort == "sizeDesc")
|
||||
val candidates = remember(library, installedItems, everything, tab, q, s.storesShowMature) {
|
||||
when (tab) {
|
||||
"library" -> library.filter(::matches)
|
||||
"installed" -> installedItems.filter(::matches)
|
||||
else -> everything.filter { matches(it) && shown(it) }
|
||||
}
|
||||
val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc")
|
||||
val candidates = remember(library, everything, tab, q, s.storesShowMature) {
|
||||
if (tab == "library") library.filter(::matches) else everything.filter { matches(it) && shown(it) }
|
||||
}
|
||||
val candidateKeys = remember(candidates) { candidates.map { it.key } }
|
||||
var resolvedSizeSortKey by remember(store, tab) { mutableStateOf<List<String>?>(null) }
|
||||
val sizeSortPending = isSizeSort && resolvedSizeSortKey != candidateKeys
|
||||
var stableVisible by remember(store, tab) { mutableStateOf(emptyList<CatalogItem>()) }
|
||||
val resolvedSizes = when {
|
||||
!isSizeSort -> emptyMap<String, Long>()
|
||||
installedOnly -> com.droiddeck.launcher.stores.StoreSizes.onDisk.toMap()
|
||||
else -> com.droiddeck.launcher.stores.StoreSizes.known.toMap()
|
||||
}
|
||||
val sizeFor = remember(resolvedSizes, installedOnly) {
|
||||
val resolvedSizes = if (isSizeSort) com.droiddeck.launcher.stores.StoreSizes.known.toMap() else emptyMap()
|
||||
val sizeFor = remember(resolvedSizes) {
|
||||
val resolved = resolvedSizes
|
||||
val lookup: (CatalogItem) -> Long = { item ->
|
||||
resolved[item.key] ?: if (installedOnly) 0L else item.sizeBytes
|
||||
}
|
||||
val lookup: (CatalogItem) -> Long = { item -> resolved[item.key] ?: item.sizeBytes }
|
||||
lookup
|
||||
}
|
||||
val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor) {
|
||||
if (tab == "library" || tab == "installed")
|
||||
sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor)
|
||||
val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor, installedKeys) {
|
||||
if (tab == "library") installedFirst(sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor), installedKeys)
|
||||
else candidates
|
||||
}
|
||||
val visible = remember(sizeSortPending, candidates, stableVisible, sortedVisible) {
|
||||
if (sizeSortPending) retainCatalogOrder(candidates, stableVisible) else sortedVisible
|
||||
}
|
||||
val installedShown = if (tab == "library") visible.count { it.id in installedKeys } else 0
|
||||
val split = installedShown.takeIf { it in 1 until visible.size } ?: 0
|
||||
SideEffect {
|
||||
if (!sizeSortPending && stableVisible != sortedVisible) stableVisible = sortedVisible
|
||||
}
|
||||
LaunchedEffect(store, tab, sort, candidates, candidateKeys) {
|
||||
if (!isSizeSort || resolvedSizeSortKey == candidateKeys) return@LaunchedEffect
|
||||
val requests = candidates.map { item ->
|
||||
val future: CompletableFuture<Long?> = if (tab == "installed") {
|
||||
StoresState.installedGame(store, item.id)?.let {
|
||||
com.droiddeck.launcher.stores.StoreSizes.resolveDisk(ctx, item.key, it)
|
||||
} ?: CompletableFuture.completedFuture<Long?>(null)
|
||||
} else {
|
||||
com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item)
|
||||
}
|
||||
item to future
|
||||
item to com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item)
|
||||
}
|
||||
CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await()
|
||||
requests.forEach { (item, future) ->
|
||||
val bytes = future.getNow(null) ?: return@forEach
|
||||
if (tab == "installed") com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key] = bytes
|
||||
else if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
|
||||
if (item.accessible && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
|
||||
}
|
||||
resolvedSizeSortKey = candidateKeys
|
||||
}
|
||||
val empty = stringResource(when {
|
||||
installedOnly -> R.string.stores_installed_empty
|
||||
tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading
|
||||
else -> R.string.stores_nothing_matches
|
||||
})
|
||||
@@ -840,7 +837,9 @@ private fun Storefront(
|
||||
val scope = rememberCoroutineScope()
|
||||
val headerItems = 1 + (if (sizeSortPending) 1 else 0) +
|
||||
(if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0)
|
||||
val listMode = s.storesView == "list" && (tab == "library" || tab == "installed")
|
||||
val listMode = s.storesView == "list" && tab == "library"
|
||||
val capsules = s.storesView == "capsules" && tab == "library"
|
||||
val tileWidth = if (capsules) CapsuleWidth else CardWidth
|
||||
val card: @Composable (CatalogItem) -> Unit = { item ->
|
||||
val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced
|
||||
firstPlaced = true
|
||||
@@ -852,14 +851,14 @@ private fun Storefront(
|
||||
else -> 0
|
||||
}
|
||||
val index = visible.indexOfFirst { it.key == item.key }
|
||||
val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction)
|
||||
val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split)
|
||||
when {
|
||||
direction == 0 || index < 0 -> false
|
||||
next == null -> direction > 0
|
||||
else -> {
|
||||
if (key.action == KeyEvent.ACTION_DOWN) {
|
||||
val target = "card:${visible[next].key}"
|
||||
val targetIndex = headerItems + next
|
||||
val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2
|
||||
val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex }
|
||||
if (!visibleTarget || (focus.attached[target] ?: 0) == 0 ||
|
||||
runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch {
|
||||
@@ -872,12 +871,12 @@ private fun Storefront(
|
||||
}
|
||||
}
|
||||
if (listMode) GameListRow(item, store, installedKeys, onOpen, first, navigation)
|
||||
else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation)
|
||||
else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation, capsule = capsules)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged {
|
||||
columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged {
|
||||
val gap = with(density) { 10.dp.roundToPx() }
|
||||
val minimum = with(density) { (if (listMode) CardWidth * 2 else CardWidth).roundToPx() }
|
||||
val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() }
|
||||
columns = ((it.width + gap) / (minimum + gap)).toInt().coerceAtLeast(1)
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -885,21 +884,19 @@ private fun Storefront(
|
||||
) {
|
||||
item(key = "header", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Rise(1) {
|
||||
val tabs = tabsFor(store)
|
||||
val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
|
||||
else stringResource(R.string.stores_tab_library)
|
||||
ModeHeader(
|
||||
when {
|
||||
tab == "store" -> stringResource(R.string.stores_tab_store)
|
||||
!loaded -> stringResource(if (installedOnly) R.string.stores_tab_installed else R.string.stores_tab_library)
|
||||
installedOnly -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedItems.size)
|
||||
else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
|
||||
},
|
||||
libraryLabel,
|
||||
query, onQuery,
|
||||
placeholder = when {
|
||||
tab == "store" -> stringResource(R.string.stores_search_catalog, store.label)
|
||||
installedOnly -> stringResource(R.string.stores_search_installed)
|
||||
else -> stringResource(R.string.stores_search_library)
|
||||
},
|
||||
placeholder = if (tab == "store") stringResource(R.string.stores_search_catalog, store.label) else stringResource(R.string.stores_search_library),
|
||||
lead = if (tabs.size < 2) null else { {
|
||||
val labels = tabs.map { if (it == "store") stringResource(R.string.stores_tab_store) else libraryLabel }
|
||||
HeaderTabs(labels, tabs.indexOf(tab).coerceAtLeast(0)) { onTab(tabs[it]) }
|
||||
} },
|
||||
) {
|
||||
if (tab == "library" || tab == "installed") {
|
||||
if (tab == "library") {
|
||||
SortButton(s, a)
|
||||
ViewButton(s, a)
|
||||
}
|
||||
@@ -925,8 +922,14 @@ private fun Storefront(
|
||||
StoresState.status[store]?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } }
|
||||
StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } }
|
||||
when (tab) {
|
||||
"library", "installed" -> if (!loaded) Unit
|
||||
else storeGrid(visible, empty, card)
|
||||
"library" -> if (!loaded) Unit
|
||||
else if (split == 0) storeGrid(visible, empty, card)
|
||||
else {
|
||||
item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) }
|
||||
storeGrid(visible.take(split), empty, card)
|
||||
item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) }
|
||||
storeGrid(visible.drop(split), empty, card)
|
||||
}
|
||||
else -> {
|
||||
if (q.isNotEmpty()) storeGrid(visible, empty, card)
|
||||
else {
|
||||
@@ -956,14 +959,48 @@ private suspend fun <T> CompletableFuture<T>.await(): T =
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
|
||||
private fun SectionLabel(title: String) {
|
||||
Text(
|
||||
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderTabs(labels: List<String>, selected: Int, onSelect: (Int) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
labels.forEachIndexed { i, label ->
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pick = { onSelect(i) }
|
||||
Text(
|
||||
label, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1,
|
||||
color = if (i == selected || hot) colors.onBackground else colors.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.clip(shape).glideBorder(hot, shape, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ModeHeader(
|
||||
title: String, query: String, onQuery: (String) -> Unit, placeholder: String,
|
||||
lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var searching by remember { mutableStateOf(query.isNotEmpty()) }
|
||||
val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen")
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
if (lead != null) lead()
|
||||
else Text(
|
||||
title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||
)
|
||||
@@ -985,19 +1022,13 @@ internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChipLabel(store: Store, ink: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.size(12.dp).clip(CircleShape).background(sourceColours(store.id).dot))
|
||||
Text(store.shortLabel, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun storePalette(base: Palette, store: Store?): Palette {
|
||||
val (accent, deep) = when (store) {
|
||||
Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1)
|
||||
Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD)
|
||||
Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800)
|
||||
Store.XBOX -> Color(0xFF70C850) to Color(0xFF107C10)
|
||||
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
||||
null -> return base
|
||||
}
|
||||
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
|
||||
@@ -1101,12 +1132,13 @@ private fun SortButton(s: FrontEndState, a: FrontEndActions) {
|
||||
|
||||
@Composable
|
||||
private fun ViewButton(s: FrontEndState, a: FrontEndActions) {
|
||||
val list = s.storesView == "list"
|
||||
val views = SessionPrefs.STORES_VIEWS
|
||||
val next = views[(views.indexOf(s.storesView) + 1) % views.size]
|
||||
HeaderIconButton(
|
||||
if (list) stringResource(R.string.stores_view_cards) else stringResource(R.string.stores_view_list),
|
||||
if (list) Icons.Filled.GridView else Icons.Filled.ViewList,
|
||||
stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }),
|
||||
when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView },
|
||||
"view",
|
||||
) { a.onStoresView(if (list) "cards" else "list") }
|
||||
) { a.onStoresView(next) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1218,6 +1250,11 @@ private fun Shelf(title: String, items: List<CatalogItem>?, card: @Composable (C
|
||||
}
|
||||
}
|
||||
|
||||
internal fun installedFirst(items: List<CatalogItem>, installed: Set<String>): List<CatalogItem> {
|
||||
val (on, off) = items.partition { it.id in installed }
|
||||
return on + off
|
||||
}
|
||||
|
||||
private fun LazyGridScope.storeGrid(games: List<CatalogItem>, empty: String, card: @Composable (CatalogItem) -> Unit) {
|
||||
if (games.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
@@ -1228,9 +1265,13 @@ private fun LazyGridScope.storeGrid(games: List<CatalogItem>, empty: String, car
|
||||
}
|
||||
|
||||
private val CardWidth = 112.dp
|
||||
private val CapsuleWidth = 92.dp
|
||||
|
||||
@Composable
|
||||
private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set<String>, onOpen: (String) -> Unit, first: Boolean = false, navigation: Modifier = Modifier) {
|
||||
private fun GameCard(
|
||||
item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set<String>, onOpen: (String) -> Unit,
|
||||
first: Boolean = false, navigation: Modifier = Modifier, capsule: Boolean = false,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -1243,7 +1284,8 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
|
||||
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
|
||||
val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() }
|
||||
val open = {
|
||||
placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) }
|
||||
val art = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl
|
||||
placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, art)) }
|
||||
onOpen(item.key)
|
||||
}
|
||||
val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() }
|
||||
@@ -1252,7 +1294,7 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.onGloballyPositioned { c ->
|
||||
placed[0] = c
|
||||
if (item.owned && item.sizeBytes <= 0) {
|
||||
if (item.accessible && item.sizeBytes <= 0) {
|
||||
val b = c.boundsInWindow()
|
||||
if (b.bottom > -ahead && b.top < screen + ahead && b.width > 0f) com.droiddeck.launcher.stores.StoreSizes.request(ctx, item)
|
||||
}
|
||||
@@ -1261,7 +1303,7 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
|
||||
.clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open),
|
||||
) {
|
||||
CardFace(item, download, hot) { CardMarker(item, installed, download) }
|
||||
CardFace(item, download, hot, capsule) { CardMarker(item, installed, download, compact = capsule) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,13 +1325,28 @@ private val CardStrip = 26.dp
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, marker: @Composable () -> Unit) {
|
||||
val url = item.imageUrl ?: item.tallImageUrl
|
||||
private fun CardFace(
|
||||
item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false,
|
||||
marker: @Composable () -> Unit,
|
||||
) {
|
||||
val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl
|
||||
val blurs = android.os.Build.VERSION.SDK_INT >= 31
|
||||
var landscape by remember(url) { mutableStateOf(false) }
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val total = maxWidth * 9f / 16f + CardStrip
|
||||
val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip
|
||||
Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
|
||||
if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
if (url != null && capsule && landscape) {
|
||||
// Stores without portrait covers (itch) would lose most of the art to a portrait crop.
|
||||
AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp))
|
||||
Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f)))
|
||||
AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip))
|
||||
} else if (url != null) AsyncImage(
|
||||
model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(),
|
||||
onSuccess = { r ->
|
||||
val art = r.result.drawable
|
||||
if (capsule && art.intrinsicWidth > 0 && art.intrinsicHeight > 0) landscape = art.intrinsicWidth > art.intrinsicHeight * 0.95f
|
||||
},
|
||||
)
|
||||
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) {
|
||||
if (url != null) AsyncImage(
|
||||
model = url, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
@@ -1317,7 +1374,7 @@ private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores.
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?) {
|
||||
private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?, compact: Boolean = false) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val small = 10.sp
|
||||
@@ -1325,13 +1382,14 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi
|
||||
installed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
val ctx = LocalContext.current
|
||||
val game = StoresState.installedGame(item.store, item.id)
|
||||
if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) }
|
||||
com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
|
||||
if (game != null && !compact) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) }
|
||||
if (!compact) com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
|
||||
Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.dp))
|
||||
}
|
||||
download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
|
||||
item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
|
||||
item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
|
||||
item.accessible && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
|
||||
item.accessible && compact -> Unit
|
||||
item.accessible -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
|
||||
item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1)
|
||||
item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
StateChip("-${item.discountPercent}%", ChipTone.DEAL, small = true)
|
||||
@@ -1407,9 +1465,16 @@ internal fun openUrl(ctx: android.content.Context, url: String) {
|
||||
|
||||
internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
|
||||
val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }
|
||||
if (game == null) {
|
||||
android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
|
||||
if (game != null) {
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) }
|
||||
return
|
||||
}
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) }
|
||||
val app = ctx.applicationContext
|
||||
Thread({
|
||||
val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
|
||||
StoresState.post {
|
||||
if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
|
||||
else com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(installed) }
|
||||
}
|
||||
}, "store-play").start()
|
||||
}
|
||||
@@ -290,7 +290,7 @@ public final class WaylandCompositor {
|
||||
|
||||
public static native void nativeSendSceneInput(int type, int a, int b);
|
||||
|
||||
public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_WINFG = 1;
|
||||
public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_DDFG = 1;
|
||||
|
||||
public static native void nativeSetFrameGenEngine(int kind);
|
||||
|
||||
@@ -300,7 +300,7 @@ public final class WaylandCompositor {
|
||||
|
||||
public static native void nativeSetFrameGenTuning(float flowScale, float refreshHz);
|
||||
|
||||
public static native void nativeSetWinFgTuning(int model, int perfPreset);
|
||||
public static native void nativeSetDdFgTuning(int model, int perfPreset);
|
||||
|
||||
public static native int nativeFrameGenProblem();
|
||||
public static native String nativeFrameGenCapsReason();
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">توليد الإطارات</string>
|
||||
<string name="frame_gen_hint">اختر وضع توليد الإطارات</string>
|
||||
<string name="frame_gen_off">إيقاف</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG تكيّفي %1$d</string>
|
||||
<string name="lsfg_needs_steam">يتطلب Lossless Scaling على Steam</string>
|
||||
@@ -2010,7 +2010,6 @@
|
||||
<string name="stores_settings_eyebrow">الإعدادات</string>
|
||||
<string name="stores_chip_signed_out">(لم يتم تسجيل الدخول)</string>
|
||||
<string name="stores_tab_store">المتجر</string>
|
||||
<string name="stores_tab_installed">المثبَّتة</string>
|
||||
<string name="stores_tab_library">المكتبة</string>
|
||||
<string name="stores_tab_all">الكل</string>
|
||||
<string name="stores_tab_downloads">التنزيلات</string>
|
||||
@@ -2022,10 +2021,8 @@
|
||||
<string name="stores_signout_stay">البقاء مسجَّل الدخول</string>
|
||||
<string name="stores_signout_go">تسجيل الخروج</string>
|
||||
<string name="stores_search_catalog">البحث في كتالوج %1$s…</string>
|
||||
<string name="stores_search_installed">البحث في الألعاب المثبَّتة…</string>
|
||||
<string name="stores_search_library">البحث في مكتبتك…</string>
|
||||
<string name="stores_search_open">البحث</string>
|
||||
<string name="stores_installed_empty">لا شيء مثبَّت</string>
|
||||
<string name="stores_library_loading">جارٍ التحميل</string>
|
||||
<string name="stores_nothing_matches">لا توجد ألعاب</string>
|
||||
<string name="stores_shelves_loading">جارٍ تحميل المتجر…</string>
|
||||
@@ -2044,7 +2041,19 @@
|
||||
<string name="stores_view_on">عرض على %1$s</string>
|
||||
<string name="stores_buy_price">شراء · %1$s</string>
|
||||
<string name="stores_installed_chip">مثبَّتة</string>
|
||||
<string name="stores_section_installed">مثبَّتة</string>
|
||||
<string name="stores_section_not_installed">غير مثبَّت</string>
|
||||
<string name="stores_in_library">في المكتبة</string>
|
||||
<string name="stores_itch_key">مفتاح API</string>
|
||||
<string name="stores_itch_get_key">الحصول على مفتاح</string>
|
||||
<string name="stores_itch_key_rejected">تم رفض المفتاح</string>
|
||||
<string name="stores_itch_not_owned">ليس في مكتبة itch.io الخاصة بك</string>
|
||||
<string name="stores_itch_no_upload">لا يوجد تنزيل لنظام Windows لهذه اللعبة</string>
|
||||
<string name="stores_itch_no_exe">لم يتم العثور على لعبة Windows في التنزيل</string>
|
||||
<string name="stores_itch_extract_failed">تعذر استخراج التنزيل (رمز الخروج %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">لم يستجب itch.io</string>
|
||||
<string name="stores_itch_archive">أرشيفات %1$s غير مدعومة</string>
|
||||
<string name="stores_itch_download_failed">استجاب itch.io بـ HTTP %1$d</string>
|
||||
<string name="stores_free">مجاني</string>
|
||||
<string name="stores_resume">استئناف</string>
|
||||
<string name="stores_refresh">تحديث</string>
|
||||
@@ -2058,6 +2067,7 @@
|
||||
<string name="stores_sort_release_date">تاريخ الإصدار</string>
|
||||
<string name="stores_view_list">عرض القائمة</string>
|
||||
<string name="stores_view_cards">عرض البطاقات</string>
|
||||
<string name="stores_view_capsules">عرض الأغلفة</string>
|
||||
<string name="stores_show_mature">إظهار المحتوى للبالغين</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Generación de fotogramas</string>
|
||||
<string name="frame_gen_hint">Elige el modo de generación de fotogramas</string>
|
||||
<string name="frame_gen_off">Desactivada</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG adaptativo %1$d</string>
|
||||
<string name="lsfg_needs_steam">Requiere Lossless Scaling en Steam</string>
|
||||
@@ -1977,7 +1977,6 @@
|
||||
<string name="stores_settings_eyebrow">Ajustes</string>
|
||||
<string name="stores_chip_signed_out">(sin iniciar sesión)</string>
|
||||
<string name="stores_tab_store">Tienda</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Descargas</string>
|
||||
@@ -1989,10 +1988,8 @@
|
||||
<string name="stores_signout_stay">Mantener la sesión</string>
|
||||
<string name="stores_signout_go">Cerrar sesión</string>
|
||||
<string name="stores_search_catalog">Buscar en el catálogo de %1$s…</string>
|
||||
<string name="stores_search_installed">Buscar juegos instalados…</string>
|
||||
<string name="stores_search_library">Buscar en tu biblioteca…</string>
|
||||
<string name="stores_search_open">Buscar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">Cargando</string>
|
||||
<string name="stores_nothing_matches">No hay juegos</string>
|
||||
<string name="stores_shelves_loading">Cargando la tienda…</string>
|
||||
@@ -2011,6 +2008,8 @@
|
||||
<string name="stores_view_on">Ver en %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">No instalada</string>
|
||||
<string name="stores_in_library">En la biblioteca</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Reanudar</string>
|
||||
@@ -2025,6 +2024,7 @@
|
||||
<string name="stores_sort_release_date">Fecha de lanzamiento</string>
|
||||
<string name="stores_view_list">Vista de lista</string>
|
||||
<string name="stores_view_cards">Vista de tarjetas</string>
|
||||
<string name="stores_view_capsules">Vista de portadas</string>
|
||||
<string name="stores_show_mature">Mostrar contenido para adultos</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
@@ -2669,6 +2669,16 @@
|
||||
<string name="boot_movie_converting_started">Convirtiendo %1$s para Steam. Mantén DroidDeck abierto.</string>
|
||||
<string name="boot_movie_unsupported">Este dispositivo no puede decodificar ese video.</string>
|
||||
<string name="boot_movie_import_failed">No se pudo importar ese video</string>
|
||||
<string name="stores_itch_key">Clave de API</string>
|
||||
<string name="stores_itch_get_key">Obtener una clave</string>
|
||||
<string name="stores_itch_key_rejected">Clave rechazada</string>
|
||||
<string name="stores_itch_not_owned">No está en tu biblioteca de itch.io</string>
|
||||
<string name="stores_itch_no_upload">Este juego no tiene descarga para Windows</string>
|
||||
<string name="stores_itch_no_exe">No se encontró un juego de Windows en la descarga</string>
|
||||
<string name="stores_itch_extract_failed">No se pudo extraer la descarga (salida %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io no respondió</string>
|
||||
<string name="stores_itch_archive">Los archivos %1$s no son compatibles</string>
|
||||
<string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string>
|
||||
<string name="glasses_experimental">Cámaras Luma Ultra — Experimental</string>
|
||||
<string name="glasses_joycons">Seguimiento de posición de los Joy-Con</string>
|
||||
<string name="glasses_joycons_hint">Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento.</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Генериране на кадри</string>
|
||||
<string name="frame_gen_hint">Изберете режима на генериране на кадри</string>
|
||||
<string name="frame_gen_off">Изключено</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Адаптивно %1$d</string>
|
||||
<string name="lsfg_needs_steam">Изисква Lossless Scaling в Steam</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Настройки</string>
|
||||
<string name="stores_chip_signed_out">(не е вписан)</string>
|
||||
<string name="stores_tab_store">Магазин</string>
|
||||
<string name="stores_tab_installed">Инсталирани</string>
|
||||
<string name="stores_tab_library">Библиотека</string>
|
||||
<string name="stores_tab_all">Всички</string>
|
||||
<string name="stores_tab_downloads">Изтегляния</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Остани вписан</string>
|
||||
<string name="stores_signout_go">Изход</string>
|
||||
<string name="stores_search_catalog">Търсене в каталога на %1$s…</string>
|
||||
<string name="stores_search_installed">Търсене в инсталираните игри…</string>
|
||||
<string name="stores_search_library">Търсене в твоята библиотека…</string>
|
||||
<string name="stores_search_open">Търсене</string>
|
||||
<string name="stores_installed_empty">Нищо инсталирано</string>
|
||||
<string name="stores_library_loading">Зареждане</string>
|
||||
<string name="stores_nothing_matches">Няма игри</string>
|
||||
<string name="stores_shelves_loading">Зареждане на магазина…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">Виж в %1$s</string>
|
||||
<string name="stores_buy_price">Купи · %1$s</string>
|
||||
<string name="stores_installed_chip">Инсталирана</string>
|
||||
<string name="stores_section_installed">Инсталирана</string>
|
||||
<string name="stores_section_not_installed">Не е инсталирано</string>
|
||||
<string name="stores_in_library">В библиотеката</string>
|
||||
<string name="stores_itch_key">API ключ</string>
|
||||
<string name="stores_itch_get_key">Вземи ключ</string>
|
||||
<string name="stores_itch_key_rejected">Ключът е отказан</string>
|
||||
<string name="stores_itch_not_owned">Не е в библиотеката ви в itch.io</string>
|
||||
<string name="stores_itch_no_upload">Няма изтегляне за Windows за тази игра</string>
|
||||
<string name="stores_itch_no_exe">В изтеглянето не е намерена игра за Windows</string>
|
||||
<string name="stores_itch_extract_failed">Изтеглянето не можа да бъде разархивирано (код %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io не отговори</string>
|
||||
<string name="stores_itch_archive">Архивите %1$s не се поддържат</string>
|
||||
<string name="stores_itch_download_failed">itch.io отговори HTTP %1$d</string>
|
||||
<string name="stores_free">Безплатна</string>
|
||||
<string name="stores_resume">Продължи</string>
|
||||
<string name="stores_refresh">Обнови</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">Дата на издаване</string>
|
||||
<string name="stores_view_list">Изглед на списък</string>
|
||||
<string name="stores_view_cards">Изглед с карти</string>
|
||||
<string name="stores_view_capsules">Изглед с обложки</string>
|
||||
<string name="stores_show_mature">Показвай съдържание за възрастни</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Generování snímků</string>
|
||||
<string name="frame_gen_hint">Vyberte režim generování snímků</string>
|
||||
<string name="frame_gen_off">Vypnuto</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptivní %1$d</string>
|
||||
<string name="lsfg_needs_steam">Vyžaduje Lossless Scaling na Steamu</string>
|
||||
@@ -1968,7 +1968,6 @@
|
||||
<string name="stores_settings_eyebrow">Nastavení</string>
|
||||
<string name="stores_chip_signed_out">(nepřihlášen)</string>
|
||||
<string name="stores_tab_store">Obchod</string>
|
||||
<string name="stores_tab_installed">Nainstalované</string>
|
||||
<string name="stores_tab_library">Knihovna</string>
|
||||
<string name="stores_tab_all">Vše</string>
|
||||
<string name="stores_tab_downloads">Stahování</string>
|
||||
@@ -1980,10 +1979,8 @@
|
||||
<string name="stores_signout_stay">Zůstat přihlášen</string>
|
||||
<string name="stores_signout_go">Odhlásit se</string>
|
||||
<string name="stores_search_catalog">Hledat v katalogu %1$s…</string>
|
||||
<string name="stores_search_installed">Hledat v nainstalovaných hrách…</string>
|
||||
<string name="stores_search_library">Hledat ve vaší knihovně…</string>
|
||||
<string name="stores_search_open">Hledat</string>
|
||||
<string name="stores_installed_empty">Nic není nainstalováno</string>
|
||||
<string name="stores_library_loading">Načítání</string>
|
||||
<string name="stores_nothing_matches">Žádné hry</string>
|
||||
<string name="stores_shelves_loading">Načítání obchodu…</string>
|
||||
@@ -2002,7 +1999,19 @@
|
||||
<string name="stores_view_on">Zobrazit na %1$s</string>
|
||||
<string name="stores_buy_price">Koupit · %1$s</string>
|
||||
<string name="stores_installed_chip">Nainstalováno</string>
|
||||
<string name="stores_section_installed">Nainstalováno</string>
|
||||
<string name="stores_section_not_installed">Není nainstalováno</string>
|
||||
<string name="stores_in_library">V knihovně</string>
|
||||
<string name="stores_itch_key">Klíč API</string>
|
||||
<string name="stores_itch_get_key">Získat klíč</string>
|
||||
<string name="stores_itch_key_rejected">Klíč odmítnut</string>
|
||||
<string name="stores_itch_not_owned">Není ve vaší knihovně itch.io</string>
|
||||
<string name="stores_itch_no_upload">Pro tuto hru není ke stažení verze pro Windows</string>
|
||||
<string name="stores_itch_no_exe">Ve stahování nebyla nalezena hra pro Windows</string>
|
||||
<string name="stores_itch_extract_failed">Stažený soubor nelze rozbalit (kód %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io neodpovědělo</string>
|
||||
<string name="stores_itch_archive">Archivy %1$s nejsou podporovány</string>
|
||||
<string name="stores_itch_download_failed">itch.io odpovědělo HTTP %1$d</string>
|
||||
<string name="stores_free">Zdarma</string>
|
||||
<string name="stores_resume">Pokračovat</string>
|
||||
<string name="stores_refresh">Obnovit</string>
|
||||
@@ -2016,6 +2025,7 @@
|
||||
<string name="stores_sort_release_date">Datum vydání</string>
|
||||
<string name="stores_view_list">Zobrazení seznamu</string>
|
||||
<string name="stores_view_cards">Zobrazení karet</string>
|
||||
<string name="stores_view_capsules">Zobrazení obalů</string>
|
||||
<string name="stores_show_mature">Zobrazovat obsah pro dospělé</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Billedgenerering</string>
|
||||
<string name="frame_gen_hint">Vælg metode til billedgenerering</string>
|
||||
<string name="frame_gen_off">Fra</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptiv %1$d</string>
|
||||
<string name="lsfg_needs_steam">Kræver Lossless Scaling på Steam</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Indstillinger</string>
|
||||
<string name="stores_chip_signed_out">(ikke logget ind)</string>
|
||||
<string name="stores_tab_store">Butik</string>
|
||||
<string name="stores_tab_installed">Installeret</string>
|
||||
<string name="stores_tab_library">Bibliotek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Bliv logget ind</string>
|
||||
<string name="stores_signout_go">Log ud</string>
|
||||
<string name="stores_search_catalog">Søg i %1$s-kataloget…</string>
|
||||
<string name="stores_search_installed">Søg i installerede spil…</string>
|
||||
<string name="stores_search_library">Søg i dit bibliotek…</string>
|
||||
<string name="stores_search_open">Søg</string>
|
||||
<string name="stores_installed_empty">Intet installeret</string>
|
||||
<string name="stores_library_loading">Indlæser</string>
|
||||
<string name="stores_nothing_matches">Ingen spil</string>
|
||||
<string name="stores_shelves_loading">Indlæser butikken…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">Se på %1$s</string>
|
||||
<string name="stores_buy_price">Køb · %1$s</string>
|
||||
<string name="stores_installed_chip">Installeret</string>
|
||||
<string name="stores_section_installed">Installeret</string>
|
||||
<string name="stores_section_not_installed">Ikke installeret</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nøgle</string>
|
||||
<string name="stores_itch_get_key">Hent en nøgle</string>
|
||||
<string name="stores_itch_key_rejected">Nøgle afvist</string>
|
||||
<string name="stores_itch_not_owned">Ikke i dit itch.io-bibliotek</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-download til dette spil</string>
|
||||
<string name="stores_itch_no_exe">Der blev ikke fundet et Windows-spil i downloaden</string>
|
||||
<string name="stores_itch_extract_failed">Downloaden kunne ikke udpakkes (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarede ikke</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiver understøttes ikke</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarede HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Genoptag</string>
|
||||
<string name="stores_refresh">Opdater</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">Udgivelsesdato</string>
|
||||
<string name="stores_view_list">Listevisning</string>
|
||||
<string name="stores_view_cards">Kortvisning</string>
|
||||
<string name="stores_view_capsules">Omslagsvisning</string>
|
||||
<string name="stores_show_mature">Vis indhold for voksne</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Frame-Generierung</string>
|
||||
<string name="frame_gen_hint">Modus der Frame-Generierung auswählen</string>
|
||||
<string name="frame_gen_off">Aus</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptiv %1$d</string>
|
||||
<string name="lsfg_needs_steam">Benötigt Lossless Scaling auf Steam</string>
|
||||
@@ -1915,7 +1915,6 @@
|
||||
<string name="stores_settings_eyebrow">Einstellungen</string>
|
||||
<string name="stores_chip_signed_out">(nicht angemeldet)</string>
|
||||
<string name="stores_tab_store">Store</string>
|
||||
<string name="stores_tab_installed">Installiert</string>
|
||||
<string name="stores_tab_library">Bibliothek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1927,10 +1926,8 @@
|
||||
<string name="stores_signout_stay">Angemeldet bleiben</string>
|
||||
<string name="stores_signout_go">Abmelden</string>
|
||||
<string name="stores_search_catalog">Katalog von %1$s durchsuchen…</string>
|
||||
<string name="stores_search_installed">Installierte Spiele durchsuchen…</string>
|
||||
<string name="stores_search_library">Deine Bibliothek durchsuchen…</string>
|
||||
<string name="stores_search_open">Suchen</string>
|
||||
<string name="stores_installed_empty">Nichts installiert</string>
|
||||
<string name="stores_library_loading">Wird geladen</string>
|
||||
<string name="stores_nothing_matches">Keine Spiele</string>
|
||||
<string name="stores_shelves_loading">Store wird geladen…</string>
|
||||
@@ -1949,7 +1946,19 @@
|
||||
<string name="stores_view_on">Auf %1$s ansehen</string>
|
||||
<string name="stores_buy_price">Kaufen · %1$s</string>
|
||||
<string name="stores_installed_chip">Installiert</string>
|
||||
<string name="stores_section_installed">Installiert</string>
|
||||
<string name="stores_section_not_installed">Nicht installiert</string>
|
||||
<string name="stores_in_library">In der Bibliothek</string>
|
||||
<string name="stores_itch_key">API-Schlüssel</string>
|
||||
<string name="stores_itch_get_key">Schlüssel holen</string>
|
||||
<string name="stores_itch_key_rejected">Schlüssel abgelehnt</string>
|
||||
<string name="stores_itch_not_owned">Nicht in deiner itch.io-Bibliothek</string>
|
||||
<string name="stores_itch_no_upload">Kein Windows-Download für dieses Spiel</string>
|
||||
<string name="stores_itch_no_exe">Im Download wurde kein Windows-Spiel gefunden</string>
|
||||
<string name="stores_itch_extract_failed">Download konnte nicht entpackt werden (Code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io hat nicht geantwortet</string>
|
||||
<string name="stores_itch_archive">%1$s-Archive werden nicht unterstützt</string>
|
||||
<string name="stores_itch_download_failed">itch.io antwortete mit HTTP %1$d</string>
|
||||
<string name="stores_free">Kostenlos</string>
|
||||
<string name="stores_resume">Fortsetzen</string>
|
||||
<string name="stores_refresh">Aktualisieren</string>
|
||||
@@ -1963,6 +1972,7 @@
|
||||
<string name="stores_sort_release_date">Erscheinungsdatum</string>
|
||||
<string name="stores_view_list">Listenansicht</string>
|
||||
<string name="stores_view_cards">Kartenansicht</string>
|
||||
<string name="stores_view_capsules">Coveransicht</string>
|
||||
<string name="stores_show_mature">Inhalte für Erwachsene anzeigen</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Δημιουργία καρέ</string>
|
||||
<string name="frame_gen_hint">Επιλέξτε τη λειτουργία δημιουργίας καρέ</string>
|
||||
<string name="frame_gen_off">Ανενεργό</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG προσαρμοστικό %1$d</string>
|
||||
<string name="lsfg_needs_steam">Απαιτεί το Lossless Scaling στο Steam</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Ρυθμίσεις</string>
|
||||
<string name="stores_chip_signed_out">(μη συνδεδεμένος)</string>
|
||||
<string name="stores_tab_store">Κατάστημα</string>
|
||||
<string name="stores_tab_installed">Εγκατεστημένα</string>
|
||||
<string name="stores_tab_library">Βιβλιοθήκη</string>
|
||||
<string name="stores_tab_all">Όλα</string>
|
||||
<string name="stores_tab_downloads">Λήψεις</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Παραμονή συνδεδεμένου</string>
|
||||
<string name="stores_signout_go">Αποσύνδεση</string>
|
||||
<string name="stores_search_catalog">Αναζήτηση στον κατάλογο %1$s…</string>
|
||||
<string name="stores_search_installed">Αναζήτηση εγκατεστημένων παιχνιδιών…</string>
|
||||
<string name="stores_search_library">Αναζήτηση στη βιβλιοθήκη σας…</string>
|
||||
<string name="stores_search_open">Αναζήτηση</string>
|
||||
<string name="stores_installed_empty">Τίποτα εγκατεστημένο</string>
|
||||
<string name="stores_library_loading">Φόρτωση</string>
|
||||
<string name="stores_nothing_matches">Δεν υπάρχουν παιχνίδια</string>
|
||||
<string name="stores_shelves_loading">Φόρτωση του καταστήματος…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">Προβολή στο %1$s</string>
|
||||
<string name="stores_buy_price">Αγορά · %1$s</string>
|
||||
<string name="stores_installed_chip">Εγκατεστημένο</string>
|
||||
<string name="stores_section_installed">Εγκατεστημένο</string>
|
||||
<string name="stores_section_not_installed">Δεν είναι εγκατεστημένο</string>
|
||||
<string name="stores_in_library">Στη βιβλιοθήκη</string>
|
||||
<string name="stores_itch_key">Κλειδί API</string>
|
||||
<string name="stores_itch_get_key">Λήψη κλειδιού</string>
|
||||
<string name="stores_itch_key_rejected">Το κλειδί απορρίφθηκε</string>
|
||||
<string name="stores_itch_not_owned">Δεν είναι στη βιβλιοθήκη σας στο itch.io</string>
|
||||
<string name="stores_itch_no_upload">Δεν υπάρχει λήψη για Windows για αυτό το παιχνίδι</string>
|
||||
<string name="stores_itch_no_exe">Δεν βρέθηκε παιχνίδι Windows στη λήψη</string>
|
||||
<string name="stores_itch_extract_failed">Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">Το itch.io δεν απάντησε</string>
|
||||
<string name="stores_itch_archive">Τα αρχεία %1$s δεν υποστηρίζονται</string>
|
||||
<string name="stores_itch_download_failed">Το itch.io απάντησε HTTP %1$d</string>
|
||||
<string name="stores_free">Δωρεάν</string>
|
||||
<string name="stores_resume">Συνέχιση</string>
|
||||
<string name="stores_refresh">Ανανέωση</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">Ημερομηνία κυκλοφορίας</string>
|
||||
<string name="stores_view_list">Προβολή λίστας</string>
|
||||
<string name="stores_view_cards">Προβολή καρτών</string>
|
||||
<string name="stores_view_capsules">Προβολή εξωφύλλων</string>
|
||||
<string name="stores_show_mature">Εμφάνιση περιεχομένου για ενήλικες</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Generación de fotogramas</string>
|
||||
<string name="frame_gen_hint">Elige el modo de generación de fotogramas</string>
|
||||
<string name="frame_gen_off">Desactivada</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG adaptativo %1$d</string>
|
||||
<string name="lsfg_needs_steam">Requiere Lossless Scaling en Steam</string>
|
||||
@@ -1944,7 +1944,6 @@
|
||||
<string name="stores_settings_eyebrow">Ajustes</string>
|
||||
<string name="stores_chip_signed_out">(sin iniciar sesión)</string>
|
||||
<string name="stores_tab_store">Tienda</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Descargas</string>
|
||||
@@ -1956,10 +1955,8 @@
|
||||
<string name="stores_signout_stay">Mantener la sesión</string>
|
||||
<string name="stores_signout_go">Cerrar sesión</string>
|
||||
<string name="stores_search_catalog">Buscar en el catálogo de %1$s…</string>
|
||||
<string name="stores_search_installed">Buscar juegos instalados…</string>
|
||||
<string name="stores_search_library">Buscar en tu biblioteca…</string>
|
||||
<string name="stores_search_open">Buscar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">Cargando</string>
|
||||
<string name="stores_nothing_matches">No hay juegos</string>
|
||||
<string name="stores_shelves_loading">Cargando la tienda…</string>
|
||||
@@ -1978,7 +1975,19 @@
|
||||
<string name="stores_view_on">Ver en %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">No instalada</string>
|
||||
<string name="stores_in_library">En la biblioteca</string>
|
||||
<string name="stores_itch_key">Clave de API</string>
|
||||
<string name="stores_itch_get_key">Obtener una clave</string>
|
||||
<string name="stores_itch_key_rejected">Clave rechazada</string>
|
||||
<string name="stores_itch_not_owned">No está en tu biblioteca de itch.io</string>
|
||||
<string name="stores_itch_no_upload">No hay descarga para Windows de este juego</string>
|
||||
<string name="stores_itch_no_exe">No se ha encontrado un juego de Windows en la descarga</string>
|
||||
<string name="stores_itch_extract_failed">No se ha podido extraer la descarga (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io no respondió</string>
|
||||
<string name="stores_itch_archive">Los archivos %1$s no son compatibles</string>
|
||||
<string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Reanudar</string>
|
||||
<string name="stores_refresh">Actualizar</string>
|
||||
@@ -1992,6 +2001,7 @@
|
||||
<string name="stores_sort_release_date">Fecha de lanzamiento</string>
|
||||
<string name="stores_view_list">Vista de lista</string>
|
||||
<string name="stores_view_cards">Vista de tarjetas</string>
|
||||
<string name="stores_view_capsules">Vista de portadas</string>
|
||||
<string name="stores_show_mature">Mostrar contenido para adultos</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Kehysten generointi</string>
|
||||
<string name="frame_gen_hint">Valitse kehysten generoinnin tila</string>
|
||||
<string name="frame_gen_off">Pois</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Mukautuva %1$d</string>
|
||||
<string name="lsfg_needs_steam">Vaatii Lossless Scalingin Steamista</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Asetukset</string>
|
||||
<string name="stores_chip_signed_out">(ei kirjautunut)</string>
|
||||
<string name="stores_tab_store">Kauppa</string>
|
||||
<string name="stores_tab_installed">Asennetut</string>
|
||||
<string name="stores_tab_library">Kirjasto</string>
|
||||
<string name="stores_tab_all">Kaikki</string>
|
||||
<string name="stores_tab_downloads">Lataukset</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Pysy kirjautuneena</string>
|
||||
<string name="stores_signout_go">Kirjaudu ulos</string>
|
||||
<string name="stores_search_catalog">Hae %1$s-luettelosta…</string>
|
||||
<string name="stores_search_installed">Hae asennetuista peleistä…</string>
|
||||
<string name="stores_search_library">Hae kirjastostasi…</string>
|
||||
<string name="stores_search_open">Hae</string>
|
||||
<string name="stores_installed_empty">Ei mitään asennettuna</string>
|
||||
<string name="stores_library_loading">Latautuu</string>
|
||||
<string name="stores_nothing_matches">Ei pelejä</string>
|
||||
<string name="stores_shelves_loading">Ladataan kauppaa…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">Näytä %1$s:ssä</string>
|
||||
<string name="stores_buy_price">Osta · %1$s</string>
|
||||
<string name="stores_installed_chip">Asennettu</string>
|
||||
<string name="stores_section_installed">Asennettu</string>
|
||||
<string name="stores_section_not_installed">Ei asennettu</string>
|
||||
<string name="stores_in_library">Kirjastossa</string>
|
||||
<string name="stores_itch_key">API-avain</string>
|
||||
<string name="stores_itch_get_key">Hae avain</string>
|
||||
<string name="stores_itch_key_rejected">Avain hylätty</string>
|
||||
<string name="stores_itch_not_owned">Ei itch.io-kirjastossasi</string>
|
||||
<string name="stores_itch_no_upload">Ei Windows-latausta tälle pelille</string>
|
||||
<string name="stores_itch_no_exe">Latauksesta ei löytynyt Windows-peliä</string>
|
||||
<string name="stores_itch_extract_failed">Latausta ei voitu purkaa (koodi %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ei vastannut</string>
|
||||
<string name="stores_itch_archive">%1$s-arkistot eivät ole tuettuja</string>
|
||||
<string name="stores_itch_download_failed">itch.io vastasi HTTP %1$d</string>
|
||||
<string name="stores_free">Ilmainen</string>
|
||||
<string name="stores_resume">Jatka</string>
|
||||
<string name="stores_refresh">Päivitä</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">Julkaisupäivä</string>
|
||||
<string name="stores_view_list">Luettelonäkymä</string>
|
||||
<string name="stores_view_cards">Korttinäkymä</string>
|
||||
<string name="stores_view_capsules">Kansinäkymä</string>
|
||||
<string name="stores_show_mature">Näytä aikuisille tarkoitettu sisältö</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Génération d\'images</string>
|
||||
<string name="frame_gen_hint">Sélectionnez le mode de génération d\'images à utiliser</string>
|
||||
<string name="frame_gen_off">Désactivé</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptatif %1$d</string>
|
||||
<string name="lsfg_needs_steam">Nécessite Lossless Scaling sur Steam</string>
|
||||
@@ -1944,7 +1944,6 @@
|
||||
<string name="stores_settings_eyebrow">Paramètres</string>
|
||||
<string name="stores_chip_signed_out">(non connecté)</string>
|
||||
<string name="stores_tab_store">Boutique</string>
|
||||
<string name="stores_tab_installed">Installés</string>
|
||||
<string name="stores_tab_library">Bibliothèque</string>
|
||||
<string name="stores_tab_all">Tous</string>
|
||||
<string name="stores_tab_downloads">Téléchargements</string>
|
||||
@@ -1956,10 +1955,8 @@
|
||||
<string name="stores_signout_stay">Rester connecté</string>
|
||||
<string name="stores_signout_go">Se déconnecter</string>
|
||||
<string name="stores_search_catalog">Rechercher dans le catalogue %1$s…</string>
|
||||
<string name="stores_search_installed">Rechercher dans les jeux installés…</string>
|
||||
<string name="stores_search_library">Rechercher dans votre bibliothèque…</string>
|
||||
<string name="stores_search_open">Rechercher</string>
|
||||
<string name="stores_installed_empty">Aucun jeu installé</string>
|
||||
<string name="stores_library_loading">Chargement</string>
|
||||
<string name="stores_nothing_matches">Aucun jeu</string>
|
||||
<string name="stores_shelves_loading">Chargement de la boutique…</string>
|
||||
@@ -1978,7 +1975,19 @@
|
||||
<string name="stores_view_on">Voir sur %1$s</string>
|
||||
<string name="stores_buy_price">Acheter · %1$s</string>
|
||||
<string name="stores_installed_chip">Installé</string>
|
||||
<string name="stores_section_installed">Installé</string>
|
||||
<string name="stores_section_not_installed">Non installé</string>
|
||||
<string name="stores_in_library">Dans la bibliothèque</string>
|
||||
<string name="stores_itch_key">Clé d\'API</string>
|
||||
<string name="stores_itch_get_key">Obtenir une clé</string>
|
||||
<string name="stores_itch_key_rejected">Clé refusée</string>
|
||||
<string name="stores_itch_not_owned">Absent de votre bibliothèque itch.io</string>
|
||||
<string name="stores_itch_no_upload">Pas de téléchargement Windows pour ce jeu</string>
|
||||
<string name="stores_itch_no_exe">Aucun jeu Windows trouvé dans le téléchargement</string>
|
||||
<string name="stores_itch_extract_failed">Impossible d\'extraire le téléchargement (code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io n\'a pas répondu</string>
|
||||
<string name="stores_itch_archive">Les archives %1$s ne sont pas prises en charge</string>
|
||||
<string name="stores_itch_download_failed">itch.io a répondu HTTP %1$d</string>
|
||||
<string name="stores_free">Gratuit</string>
|
||||
<string name="stores_resume">Reprendre</string>
|
||||
<string name="stores_refresh">Actualiser</string>
|
||||
@@ -1992,6 +2001,7 @@
|
||||
<string name="stores_sort_release_date">Date de sortie</string>
|
||||
<string name="stores_view_list">Vue liste</string>
|
||||
<string name="stores_view_cards">Vue cartes</string>
|
||||
<string name="stores_view_capsules">Vue jaquettes</string>
|
||||
<string name="stores_show_mature">Afficher le contenu pour adultes</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">फ़्रेम जनरेशन</string>
|
||||
<string name="frame_gen_hint">फ़्रेम जनरेशन मोड चुनें</string>
|
||||
<string name="frame_gen_off">बंद</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG एडैप्टिव %1$d</string>
|
||||
<string name="lsfg_needs_steam">Steam पर Lossless Scaling की ज़रूरत है</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">सेटिंग</string>
|
||||
<string name="stores_chip_signed_out">(साइन इन नहीं है)</string>
|
||||
<string name="stores_tab_store">स्टोर</string>
|
||||
<string name="stores_tab_installed">इंस्टॉल किए</string>
|
||||
<string name="stores_tab_library">लाइब्रेरी</string>
|
||||
<string name="stores_tab_all">सभी</string>
|
||||
<string name="stores_tab_downloads">डाउनलोड</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">साइन इन रहें</string>
|
||||
<string name="stores_signout_go">साइन आउट करें</string>
|
||||
<string name="stores_search_catalog">%1$s के कैटलॉग में सर्च करें…</string>
|
||||
<string name="stores_search_installed">इंस्टॉल किए गेम में सर्च करें…</string>
|
||||
<string name="stores_search_library">अपनी लाइब्रेरी में सर्च करें…</string>
|
||||
<string name="stores_search_open">सर्च</string>
|
||||
<string name="stores_installed_empty">कुछ इंस्टॉल नहीं है</string>
|
||||
<string name="stores_library_loading">लोड हो रहा है</string>
|
||||
<string name="stores_nothing_matches">कोई गेम नहीं</string>
|
||||
<string name="stores_shelves_loading">स्टोर लोड हो रहा है…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">%1$s पर देखें</string>
|
||||
<string name="stores_buy_price">खरीदें · %1$s</string>
|
||||
<string name="stores_installed_chip">इंस्टॉल है</string>
|
||||
<string name="stores_section_installed">इंस्टॉल है</string>
|
||||
<string name="stores_section_not_installed">इंस्टॉल नहीं है</string>
|
||||
<string name="stores_in_library">लाइब्रेरी में</string>
|
||||
<string name="stores_itch_key">API कुंजी</string>
|
||||
<string name="stores_itch_get_key">कुंजी पाएं</string>
|
||||
<string name="stores_itch_key_rejected">कुंजी अस्वीकृत</string>
|
||||
<string name="stores_itch_not_owned">आपकी itch.io लाइब्रेरी में नहीं</string>
|
||||
<string name="stores_itch_no_upload">इस गेम के लिए Windows डाउनलोड नहीं है</string>
|
||||
<string name="stores_itch_no_exe">डाउनलोड में कोई Windows गेम नहीं मिला</string>
|
||||
<string name="stores_itch_extract_failed">डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ने जवाब नहीं दिया</string>
|
||||
<string name="stores_itch_archive">%1$s आर्काइव समर्थित नहीं हैं</string>
|
||||
<string name="stores_itch_download_failed">itch.io ने HTTP %1$d लौटाया</string>
|
||||
<string name="stores_free">फ़्री</string>
|
||||
<string name="stores_resume">फिर से शुरू करें</string>
|
||||
<string name="stores_refresh">रीफ़्रेश करें</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">रिलीज़ तिथि</string>
|
||||
<string name="stores_view_list">सूची दृश्य</string>
|
||||
<string name="stores_view_cards">कार्ड दृश्य</string>
|
||||
<string name="stores_view_capsules">कवर व्यू</string>
|
||||
<string name="stores_show_mature">वयस्क कंटेंट दिखाएं</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Képkocka-generálás</string>
|
||||
<string name="frame_gen_hint">Válassza ki a képkocka-generálás módját</string>
|
||||
<string name="frame_gen_off">Ki</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG adaptív %1$d</string>
|
||||
<string name="lsfg_needs_steam">A Lossless Scaling szükséges a Steamen</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Beállítások</string>
|
||||
<string name="stores_chip_signed_out">(nincs bejelentkezve)</string>
|
||||
<string name="stores_tab_store">Áruház</string>
|
||||
<string name="stores_tab_installed">Telepítve</string>
|
||||
<string name="stores_tab_library">Könyvtár</string>
|
||||
<string name="stores_tab_all">Összes</string>
|
||||
<string name="stores_tab_downloads">Letöltések</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Bejelentkezve marad</string>
|
||||
<string name="stores_signout_go">Kijelentkezés</string>
|
||||
<string name="stores_search_catalog">Keresés a %1$s katalógusban…</string>
|
||||
<string name="stores_search_installed">Keresés a telepített játékok között…</string>
|
||||
<string name="stores_search_library">Keresés a könyvtárában…</string>
|
||||
<string name="stores_search_open">Keresés</string>
|
||||
<string name="stores_installed_empty">Nincs telepítve semmi</string>
|
||||
<string name="stores_library_loading">Betöltés</string>
|
||||
<string name="stores_nothing_matches">Nincs játék</string>
|
||||
<string name="stores_shelves_loading">Az áruház betöltése…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">Megtekintés itt: %1$s</string>
|
||||
<string name="stores_buy_price">Megvétel · %1$s</string>
|
||||
<string name="stores_installed_chip">Telepítve</string>
|
||||
<string name="stores_section_installed">Telepítve</string>
|
||||
<string name="stores_section_not_installed">Nincs telepítve</string>
|
||||
<string name="stores_in_library">A könyvtárban</string>
|
||||
<string name="stores_itch_key">API-kulcs</string>
|
||||
<string name="stores_itch_get_key">Kulcs beszerzése</string>
|
||||
<string name="stores_itch_key_rejected">A kulcs elutasítva</string>
|
||||
<string name="stores_itch_not_owned">Nincs az itch.io-könyvtáradban</string>
|
||||
<string name="stores_itch_no_upload">Ehhez a játékhoz nincs Windows-letöltés</string>
|
||||
<string name="stores_itch_no_exe">A letöltésben nem található Windows-játék</string>
|
||||
<string name="stores_itch_extract_failed">A letöltés nem bontható ki (kód: %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">Az itch.io nem válaszolt</string>
|
||||
<string name="stores_itch_archive">A(z) %1$s archívumok nem támogatottak</string>
|
||||
<string name="stores_itch_download_failed">Az itch.io HTTP %1$d választ adott</string>
|
||||
<string name="stores_free">Ingyenes</string>
|
||||
<string name="stores_resume">Folytatás</string>
|
||||
<string name="stores_refresh">Frissítés</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">Megjelenés dátuma</string>
|
||||
<string name="stores_view_list">Listanézet</string>
|
||||
<string name="stores_view_cards">Kártyanézet</string>
|
||||
<string name="stores_view_capsules">Borítónézet</string>
|
||||
<string name="stores_show_mature">Felnőtt tartalom megjelenítése</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Pembuatan frame</string>
|
||||
<string name="frame_gen_hint">Pilih mode pembuatan frame</string>
|
||||
<string name="frame_gen_off">Nonaktif</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptif %1$d</string>
|
||||
<string name="lsfg_needs_steam">Memerlukan Lossless Scaling di Steam</string>
|
||||
@@ -1905,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">Setelan</string>
|
||||
<string name="stores_chip_signed_out">(belum masuk)</string>
|
||||
<string name="stores_tab_store">Toko</string>
|
||||
<string name="stores_tab_installed">Terinstal</string>
|
||||
<string name="stores_tab_library">Pustaka</string>
|
||||
<string name="stores_tab_all">Semua</string>
|
||||
<string name="stores_tab_downloads">Unduhan</string>
|
||||
@@ -1917,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">Tetap masuk</string>
|
||||
<string name="stores_signout_go">Keluar</string>
|
||||
<string name="stores_search_catalog">Cari katalog %1$s…</string>
|
||||
<string name="stores_search_installed">Cari gim yang terinstal…</string>
|
||||
<string name="stores_search_library">Cari pustaka Anda…</string>
|
||||
<string name="stores_search_open">Cari</string>
|
||||
<string name="stores_installed_empty">Belum ada yang terinstal</string>
|
||||
<string name="stores_library_loading">Memuat</string>
|
||||
<string name="stores_nothing_matches">Tidak ada gim</string>
|
||||
<string name="stores_shelves_loading">Memuat toko…</string>
|
||||
@@ -1939,7 +1936,19 @@
|
||||
<string name="stores_view_on">Lihat di %1$s</string>
|
||||
<string name="stores_buy_price">Beli · %1$s</string>
|
||||
<string name="stores_installed_chip">Terinstal</string>
|
||||
<string name="stores_section_installed">Terinstal</string>
|
||||
<string name="stores_section_not_installed">Belum terinstal</string>
|
||||
<string name="stores_in_library">Dalam pustaka</string>
|
||||
<string name="stores_itch_key">Kunci API</string>
|
||||
<string name="stores_itch_get_key">Dapatkan kunci</string>
|
||||
<string name="stores_itch_key_rejected">Kunci ditolak</string>
|
||||
<string name="stores_itch_not_owned">Tidak ada di pustaka itch.io Anda</string>
|
||||
<string name="stores_itch_no_upload">Tidak ada unduhan Windows untuk game ini</string>
|
||||
<string name="stores_itch_no_exe">Tidak ada game Windows di unduhan</string>
|
||||
<string name="stores_itch_extract_failed">Tidak dapat mengekstrak unduhan (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io tidak menjawab</string>
|
||||
<string name="stores_itch_archive">Arsip %1$s tidak didukung</string>
|
||||
<string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Lanjutkan</string>
|
||||
<string name="stores_refresh">Segarkan</string>
|
||||
@@ -1953,6 +1962,7 @@
|
||||
<string name="stores_sort_release_date">Tanggal rilis</string>
|
||||
<string name="stores_view_list">Tampilan daftar</string>
|
||||
<string name="stores_view_cards">Tampilan kartu</string>
|
||||
<string name="stores_view_capsules">Tampilan sampul</string>
|
||||
<string name="stores_show_mature">Tampilkan konten dewasa</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Generazione di frame</string>
|
||||
<string name="frame_gen_hint">Seleziona la modalità di generazione di frame</string>
|
||||
<string name="frame_gen_off">Disattivata</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG adattivo %1$d</string>
|
||||
<string name="lsfg_needs_steam">Richiede Lossless Scaling su Steam</string>
|
||||
@@ -1936,7 +1936,6 @@
|
||||
<string name="stores_settings_eyebrow">Impostazioni</string>
|
||||
<string name="stores_chip_signed_out">(accesso non eseguito)</string>
|
||||
<string name="stores_tab_store">Negozio</string>
|
||||
<string name="stores_tab_installed">Installati</string>
|
||||
<string name="stores_tab_library">Libreria</string>
|
||||
<string name="stores_tab_all">Tutti</string>
|
||||
<string name="stores_tab_downloads">Download</string>
|
||||
@@ -1948,10 +1947,8 @@
|
||||
<string name="stores_signout_stay">Resta connesso</string>
|
||||
<string name="stores_signout_go">Esci</string>
|
||||
<string name="stores_search_catalog">Cerca nel catalogo di %1$s…</string>
|
||||
<string name="stores_search_installed">Cerca tra i giochi installati…</string>
|
||||
<string name="stores_search_library">Cerca nella tua libreria…</string>
|
||||
<string name="stores_search_open">Cerca</string>
|
||||
<string name="stores_installed_empty">Niente di installato</string>
|
||||
<string name="stores_library_loading">Caricamento</string>
|
||||
<string name="stores_nothing_matches">Nessun gioco</string>
|
||||
<string name="stores_shelves_loading">Caricamento del negozio…</string>
|
||||
@@ -1970,7 +1967,19 @@
|
||||
<string name="stores_view_on">Visualizza su %1$s</string>
|
||||
<string name="stores_buy_price">Acquista · %1$s</string>
|
||||
<string name="stores_installed_chip">Installato</string>
|
||||
<string name="stores_section_installed">Installato</string>
|
||||
<string name="stores_section_not_installed">Non installata</string>
|
||||
<string name="stores_in_library">In libreria</string>
|
||||
<string name="stores_itch_key">Chiave API</string>
|
||||
<string name="stores_itch_get_key">Ottieni una chiave</string>
|
||||
<string name="stores_itch_key_rejected">Chiave rifiutata</string>
|
||||
<string name="stores_itch_not_owned">Non è nella tua libreria itch.io</string>
|
||||
<string name="stores_itch_no_upload">Nessun download per Windows di questo gioco</string>
|
||||
<string name="stores_itch_no_exe">Nessun gioco Windows trovato nel download</string>
|
||||
<string name="stores_itch_extract_failed">Impossibile estrarre il download (codice %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io non ha risposto</string>
|
||||
<string name="stores_itch_archive">Gli archivi %1$s non sono supportati</string>
|
||||
<string name="stores_itch_download_failed">itch.io ha risposto HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Riprendi</string>
|
||||
<string name="stores_refresh">Aggiorna</string>
|
||||
@@ -1984,6 +1993,7 @@
|
||||
<string name="stores_sort_release_date">Data di uscita</string>
|
||||
<string name="stores_view_list">Visualizzazione elenco</string>
|
||||
<string name="stores_view_cards">Visualizzazione schede</string>
|
||||
<string name="stores_view_capsules">Vista copertine</string>
|
||||
<string name="stores_show_mature">Mostra contenuti per adulti</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">フレーム生成</string>
|
||||
<string name="frame_gen_hint">使用するフレーム生成モードを選択してください</string>
|
||||
<string name="frame_gen_off">オフ</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG アダプティブ %1$d</string>
|
||||
<string name="lsfg_needs_steam">Steam版のLossless Scalingが必要です</string>
|
||||
@@ -1905,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">設定</string>
|
||||
<string name="stores_chip_signed_out">(未ログイン)</string>
|
||||
<string name="stores_tab_store">ストア</string>
|
||||
<string name="stores_tab_installed">インストール済み</string>
|
||||
<string name="stores_tab_library">ライブラリ</string>
|
||||
<string name="stores_tab_all">すべて</string>
|
||||
<string name="stores_tab_downloads">ダウンロード</string>
|
||||
@@ -1917,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">ログインしたままにする</string>
|
||||
<string name="stores_signout_go">ログアウト</string>
|
||||
<string name="stores_search_catalog">%1$sのカタログを検索…</string>
|
||||
<string name="stores_search_installed">インストール済みのゲームを検索…</string>
|
||||
<string name="stores_search_library">ライブラリを検索…</string>
|
||||
<string name="stores_search_open">検索</string>
|
||||
<string name="stores_installed_empty">何もインストールされていません</string>
|
||||
<string name="stores_library_loading">読み込み中</string>
|
||||
<string name="stores_nothing_matches">ゲームがありません</string>
|
||||
<string name="stores_shelves_loading">ストアを読み込み中…</string>
|
||||
@@ -1939,7 +1936,19 @@
|
||||
<string name="stores_view_on">%1$sで見る</string>
|
||||
<string name="stores_buy_price">購入 · %1$s</string>
|
||||
<string name="stores_installed_chip">インストール済み</string>
|
||||
<string name="stores_section_installed">インストール済み</string>
|
||||
<string name="stores_section_not_installed">未インストール</string>
|
||||
<string name="stores_in_library">ライブラリにあり</string>
|
||||
<string name="stores_itch_key">APIキー</string>
|
||||
<string name="stores_itch_get_key">キーを取得</string>
|
||||
<string name="stores_itch_key_rejected">キーが拒否されました</string>
|
||||
<string name="stores_itch_not_owned">itch.io ライブラリにありません</string>
|
||||
<string name="stores_itch_no_upload">このゲームには Windows 版のダウンロードがありません</string>
|
||||
<string name="stores_itch_no_exe">ダウンロードに Windows 版のゲームが見つかりません</string>
|
||||
<string name="stores_itch_extract_failed">ダウンロードを展開できませんでした (終了コード %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io から応答がありません</string>
|
||||
<string name="stores_itch_archive">%1$s 形式のアーカイブには対応していません</string>
|
||||
<string name="stores_itch_download_failed">itch.io が HTTP %1$d を返しました</string>
|
||||
<string name="stores_free">無料</string>
|
||||
<string name="stores_resume">再開</string>
|
||||
<string name="stores_refresh">更新</string>
|
||||
@@ -1953,6 +1962,7 @@
|
||||
<string name="stores_sort_release_date">リリース日</string>
|
||||
<string name="stores_view_list">リスト表示</string>
|
||||
<string name="stores_view_cards">カード表示</string>
|
||||
<string name="stores_view_capsules">パッケージ表示</string>
|
||||
<string name="stores_show_mature">成人向けコンテンツを表示</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">프레임 생성</string>
|
||||
<string name="frame_gen_hint">사용할 프레임 생성 모드를 선택하세요</string>
|
||||
<string name="frame_gen_off">끔</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG 적응형 %1$d</string>
|
||||
<string name="lsfg_needs_steam">Steam에서 Lossless Scaling이 필요합니다</string>
|
||||
@@ -1905,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">설정</string>
|
||||
<string name="stores_chip_signed_out">(로그인 안 됨)</string>
|
||||
<string name="stores_tab_store">스토어</string>
|
||||
<string name="stores_tab_installed">설치됨</string>
|
||||
<string name="stores_tab_library">라이브러리</string>
|
||||
<string name="stores_tab_all">전체</string>
|
||||
<string name="stores_tab_downloads">다운로드</string>
|
||||
@@ -1917,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">로그인 유지</string>
|
||||
<string name="stores_signout_go">로그아웃</string>
|
||||
<string name="stores_search_catalog">%1$s 카탈로그 검색…</string>
|
||||
<string name="stores_search_installed">설치된 게임 검색…</string>
|
||||
<string name="stores_search_library">내 라이브러리 검색…</string>
|
||||
<string name="stores_search_open">검색</string>
|
||||
<string name="stores_installed_empty">설치된 항목 없음</string>
|
||||
<string name="stores_library_loading">불러오는 중</string>
|
||||
<string name="stores_nothing_matches">게임 없음</string>
|
||||
<string name="stores_shelves_loading">스토어 불러오는 중…</string>
|
||||
@@ -1939,7 +1936,19 @@
|
||||
<string name="stores_view_on">%1$s에서 보기</string>
|
||||
<string name="stores_buy_price">구매 · %1$s</string>
|
||||
<string name="stores_installed_chip">설치됨</string>
|
||||
<string name="stores_section_installed">설치됨</string>
|
||||
<string name="stores_section_not_installed">설치되지 않음</string>
|
||||
<string name="stores_in_library">라이브러리에 있음</string>
|
||||
<string name="stores_itch_key">API 키</string>
|
||||
<string name="stores_itch_get_key">키 받기</string>
|
||||
<string name="stores_itch_key_rejected">키가 거부되었습니다</string>
|
||||
<string name="stores_itch_not_owned">itch.io 라이브러리에 없습니다</string>
|
||||
<string name="stores_itch_no_upload">이 게임의 Windows 다운로드가 없습니다</string>
|
||||
<string name="stores_itch_no_exe">다운로드에서 Windows 게임을 찾을 수 없습니다</string>
|
||||
<string name="stores_itch_extract_failed">다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io가 응답하지 않았습니다</string>
|
||||
<string name="stores_itch_archive">%1$s 압축 파일은 지원하지 않습니다</string>
|
||||
<string name="stores_itch_download_failed">itch.io가 HTTP %1$d을(를) 반환했습니다</string>
|
||||
<string name="stores_free">무료</string>
|
||||
<string name="stores_resume">재개</string>
|
||||
<string name="stores_refresh">새로 고침</string>
|
||||
@@ -1953,6 +1962,7 @@
|
||||
<string name="stores_sort_release_date">출시일</string>
|
||||
<string name="stores_view_list">목록 보기</string>
|
||||
<string name="stores_view_cards">카드 보기</string>
|
||||
<string name="stores_view_capsules">표지 보기</string>
|
||||
<string name="stores_show_mature">성인용 콘텐츠 표시</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Penjanaan bingkai</string>
|
||||
<string name="frame_gen_hint">Pilih mod penjanaan bingkai</string>
|
||||
<string name="frame_gen_off">Mati</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptif %1$d</string>
|
||||
<string name="lsfg_needs_steam">Memerlukan Lossless Scaling pada Steam</string>
|
||||
@@ -1905,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">Tetapan</string>
|
||||
<string name="stores_chip_signed_out">(belum log masuk)</string>
|
||||
<string name="stores_tab_store">Kedai</string>
|
||||
<string name="stores_tab_installed">Dipasang</string>
|
||||
<string name="stores_tab_library">Pustaka</string>
|
||||
<string name="stores_tab_all">Semua</string>
|
||||
<string name="stores_tab_downloads">Muat turun</string>
|
||||
@@ -1917,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">Kekal log masuk</string>
|
||||
<string name="stores_signout_go">Log keluar</string>
|
||||
<string name="stores_search_catalog">Cari katalog %1$s…</string>
|
||||
<string name="stores_search_installed">Cari permainan dipasang…</string>
|
||||
<string name="stores_search_library">Cari pustaka anda…</string>
|
||||
<string name="stores_search_open">Cari</string>
|
||||
<string name="stores_installed_empty">Tiada apa dipasang</string>
|
||||
<string name="stores_library_loading">Memuatkan</string>
|
||||
<string name="stores_nothing_matches">Tiada permainan</string>
|
||||
<string name="stores_shelves_loading">Memuatkan kedai…</string>
|
||||
@@ -1939,7 +1936,19 @@
|
||||
<string name="stores_view_on">Lihat pada %1$s</string>
|
||||
<string name="stores_buy_price">Beli · %1$s</string>
|
||||
<string name="stores_installed_chip">Dipasang</string>
|
||||
<string name="stores_section_installed">Dipasang</string>
|
||||
<string name="stores_section_not_installed">Belum dipasang</string>
|
||||
<string name="stores_in_library">Dalam pustaka</string>
|
||||
<string name="stores_itch_key">Kunci API</string>
|
||||
<string name="stores_itch_get_key">Dapatkan kunci</string>
|
||||
<string name="stores_itch_key_rejected">Kunci ditolak</string>
|
||||
<string name="stores_itch_not_owned">Tiada dalam pustaka itch.io anda</string>
|
||||
<string name="stores_itch_no_upload">Tiada muat turun Windows untuk permainan ini</string>
|
||||
<string name="stores_itch_no_exe">Tiada permainan Windows ditemui dalam muat turun</string>
|
||||
<string name="stores_itch_extract_failed">Tidak dapat mengekstrak muat turun (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io tidak menjawab</string>
|
||||
<string name="stores_itch_archive">Arkib %1$s tidak disokong</string>
|
||||
<string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string>
|
||||
<string name="stores_free">Percuma</string>
|
||||
<string name="stores_resume">Sambung</string>
|
||||
<string name="stores_refresh">Segar semula</string>
|
||||
@@ -1953,6 +1962,7 @@
|
||||
<string name="stores_sort_release_date">Tarikh keluaran</string>
|
||||
<string name="stores_view_list">Paparan senarai</string>
|
||||
<string name="stores_view_cards">Paparan kad</string>
|
||||
<string name="stores_view_capsules">Paparan kulit</string>
|
||||
<string name="stores_show_mature">Papar kandungan matang</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Bildegenerering</string>
|
||||
<string name="frame_gen_hint">Velg modus for bildegenerering</string>
|
||||
<string name="frame_gen_off">Av</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptiv %1$d</string>
|
||||
<string name="lsfg_needs_steam">Krever Lossless Scaling på Steam</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Innstillinger</string>
|
||||
<string name="stores_chip_signed_out">(ikke innlogget)</string>
|
||||
<string name="stores_tab_store">Butikk</string>
|
||||
<string name="stores_tab_installed">Installert</string>
|
||||
<string name="stores_tab_library">Bibliotek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Nedlastinger</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Fortsett innlogget</string>
|
||||
<string name="stores_signout_go">Logg ut</string>
|
||||
<string name="stores_search_catalog">Søk i %1$s-katalogen…</string>
|
||||
<string name="stores_search_installed">Søk i installerte spill…</string>
|
||||
<string name="stores_search_library">Søk i biblioteket ditt…</string>
|
||||
<string name="stores_search_open">Søk</string>
|
||||
<string name="stores_installed_empty">Ingenting installert</string>
|
||||
<string name="stores_library_loading">Laster</string>
|
||||
<string name="stores_nothing_matches">Ingen spill</string>
|
||||
<string name="stores_shelves_loading">Laster butikken…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">Vis på %1$s</string>
|
||||
<string name="stores_buy_price">Kjøp · %1$s</string>
|
||||
<string name="stores_installed_chip">Installert</string>
|
||||
<string name="stores_section_installed">Installert</string>
|
||||
<string name="stores_section_not_installed">Ikke installert</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nøkkel</string>
|
||||
<string name="stores_itch_get_key">Hent en nøkkel</string>
|
||||
<string name="stores_itch_key_rejected">Nøkkelen ble avvist</string>
|
||||
<string name="stores_itch_not_owned">Ikke i itch.io-biblioteket ditt</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-nedlasting for dette spillet</string>
|
||||
<string name="stores_itch_no_exe">Fant ikke noe Windows-spill i nedlastingen</string>
|
||||
<string name="stores_itch_extract_failed">Kunne ikke pakke ut nedlastingen (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarte ikke</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiver støttes ikke</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarte HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Gjenoppta</string>
|
||||
<string name="stores_refresh">Oppdater</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">Utgivelsesdato</string>
|
||||
<string name="stores_view_list">Listevisning</string>
|
||||
<string name="stores_view_cards">Kortvisning</string>
|
||||
<string name="stores_view_capsules">Omslagsvisning</string>
|
||||
<string name="stores_show_mature">Vis innhold for voksne</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Framegeneratie</string>
|
||||
<string name="frame_gen_hint">Kies de modus voor framegeneratie</string>
|
||||
<string name="frame_gen_off">Uit</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG adaptief %1$d</string>
|
||||
<string name="lsfg_needs_steam">Vereist Lossless Scaling op Steam</string>
|
||||
@@ -1926,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Instellingen</string>
|
||||
<string name="stores_chip_signed_out">(niet aangemeld)</string>
|
||||
<string name="stores_tab_store">Winkel</string>
|
||||
<string name="stores_tab_installed">Geïnstalleerd</string>
|
||||
<string name="stores_tab_library">Bibliotheek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1938,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Aangemeld blijven</string>
|
||||
<string name="stores_signout_go">Afmelden</string>
|
||||
<string name="stores_search_catalog">Zoeken in de catalogus van %1$s…</string>
|
||||
<string name="stores_search_installed">Geïnstalleerde games zoeken…</string>
|
||||
<string name="stores_search_library">Zoeken in je bibliotheek…</string>
|
||||
<string name="stores_search_open">Zoeken</string>
|
||||
<string name="stores_installed_empty">Niets geïnstalleerd</string>
|
||||
<string name="stores_library_loading">Laden</string>
|
||||
<string name="stores_nothing_matches">Geen games</string>
|
||||
<string name="stores_shelves_loading">Winkel laden…</string>
|
||||
@@ -1960,7 +1957,19 @@
|
||||
<string name="stores_view_on">Bekijken op %1$s</string>
|
||||
<string name="stores_buy_price">Kopen · %1$s</string>
|
||||
<string name="stores_installed_chip">Geïnstalleerd</string>
|
||||
<string name="stores_section_installed">Geïnstalleerd</string>
|
||||
<string name="stores_section_not_installed">Niet geïnstalleerd</string>
|
||||
<string name="stores_in_library">In bibliotheek</string>
|
||||
<string name="stores_itch_key">API-sleutel</string>
|
||||
<string name="stores_itch_get_key">Sleutel ophalen</string>
|
||||
<string name="stores_itch_key_rejected">Sleutel geweigerd</string>
|
||||
<string name="stores_itch_not_owned">Niet in je itch.io-bibliotheek</string>
|
||||
<string name="stores_itch_no_upload">Geen Windows-download voor dit spel</string>
|
||||
<string name="stores_itch_no_exe">Geen Windows-game gevonden in de download</string>
|
||||
<string name="stores_itch_extract_failed">Download kan niet worden uitgepakt (code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io gaf geen antwoord</string>
|
||||
<string name="stores_itch_archive">%1$s-archieven worden niet ondersteund</string>
|
||||
<string name="stores_itch_download_failed">itch.io antwoordde HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Hervatten</string>
|
||||
<string name="stores_refresh">Vernieuwen</string>
|
||||
@@ -1974,6 +1983,7 @@
|
||||
<string name="stores_sort_release_date">Releasedatum</string>
|
||||
<string name="stores_view_list">Lijstweergave</string>
|
||||
<string name="stores_view_cards">Kaartweergave</string>
|
||||
<string name="stores_view_capsules">Hoesweergave</string>
|
||||
<string name="stores_show_mature">Inhoud voor volwassenen tonen</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Generowanie klatek</string>
|
||||
<string name="frame_gen_hint">Wybierz tryb generowania klatek</string>
|
||||
<string name="frame_gen_off">Wył.</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG adaptacyjne %1$d</string>
|
||||
<string name="lsfg_needs_steam">Wymaga Lossless Scaling ze Steam</string>
|
||||
@@ -1968,7 +1968,6 @@
|
||||
<string name="stores_settings_eyebrow">Ustawienia</string>
|
||||
<string name="stores_chip_signed_out">(niezalogowano)</string>
|
||||
<string name="stores_tab_store">Sklep</string>
|
||||
<string name="stores_tab_installed">Zainstalowane</string>
|
||||
<string name="stores_tab_library">Biblioteka</string>
|
||||
<string name="stores_tab_all">Wszystkie</string>
|
||||
<string name="stores_tab_downloads">Pobierania</string>
|
||||
@@ -1980,10 +1979,8 @@
|
||||
<string name="stores_signout_stay">Zostań zalogowany</string>
|
||||
<string name="stores_signout_go">Wyloguj się</string>
|
||||
<string name="stores_search_catalog">Szukaj w katalogu %1$s…</string>
|
||||
<string name="stores_search_installed">Szukaj zainstalowanych gier…</string>
|
||||
<string name="stores_search_library">Szukaj w Twojej bibliotece…</string>
|
||||
<string name="stores_search_open">Szukaj</string>
|
||||
<string name="stores_installed_empty">Nic nie zainstalowano</string>
|
||||
<string name="stores_library_loading">Wczytywanie</string>
|
||||
<string name="stores_nothing_matches">Brak gier</string>
|
||||
<string name="stores_shelves_loading">Wczytywanie sklepu…</string>
|
||||
@@ -2002,7 +1999,19 @@
|
||||
<string name="stores_view_on">Zobacz w %1$s</string>
|
||||
<string name="stores_buy_price">Kup · %1$s</string>
|
||||
<string name="stores_installed_chip">Zainstalowano</string>
|
||||
<string name="stores_section_installed">Zainstalowano</string>
|
||||
<string name="stores_section_not_installed">Nie zainstalowano</string>
|
||||
<string name="stores_in_library">W bibliotece</string>
|
||||
<string name="stores_itch_key">Klucz API</string>
|
||||
<string name="stores_itch_get_key">Pobierz klucz</string>
|
||||
<string name="stores_itch_key_rejected">Klucz odrzucony</string>
|
||||
<string name="stores_itch_not_owned">Nie ma go w Twojej bibliotece itch.io</string>
|
||||
<string name="stores_itch_no_upload">Brak wersji na Windows dla tej gry</string>
|
||||
<string name="stores_itch_no_exe">W pobranych plikach nie znaleziono gry na Windows</string>
|
||||
<string name="stores_itch_extract_failed">Nie można rozpakować pobranego pliku (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io nie odpowiedziało</string>
|
||||
<string name="stores_itch_archive">Archiwa %1$s nie są obsługiwane</string>
|
||||
<string name="stores_itch_download_failed">itch.io odpowiedziało HTTP %1$d</string>
|
||||
<string name="stores_free">Bezpłatna</string>
|
||||
<string name="stores_resume">Wznów</string>
|
||||
<string name="stores_refresh">Odśwież</string>
|
||||
@@ -2016,6 +2025,7 @@
|
||||
<string name="stores_sort_release_date">Data wydania</string>
|
||||
<string name="stores_view_list">Widok listy</string>
|
||||
<string name="stores_view_cards">Widok kart</string>
|
||||
<string name="stores_view_capsules">Widok okładek</string>
|
||||
<string name="stores_show_mature">Pokaż treści dla dorosłych</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Geração de fotogramas</string>
|
||||
<string name="frame_gen_hint">Selecione o modo de geração de fotogramas</string>
|
||||
<string name="frame_gen_off">Desligado</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptativo %1$d</string>
|
||||
<string name="lsfg_needs_steam">Requer o Lossless Scaling no Steam</string>
|
||||
@@ -1936,7 +1936,6 @@
|
||||
<string name="stores_settings_eyebrow">Definições</string>
|
||||
<string name="stores_chip_signed_out">(sessão não iniciada)</string>
|
||||
<string name="stores_tab_store">Loja</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Transferências</string>
|
||||
@@ -1948,10 +1947,8 @@
|
||||
<string name="stores_signout_stay">Manter sessão iniciada</string>
|
||||
<string name="stores_signout_go">Terminar sessão</string>
|
||||
<string name="stores_search_catalog">Pesquisar no catálogo %1$s…</string>
|
||||
<string name="stores_search_installed">Pesquisar jogos instalados…</string>
|
||||
<string name="stores_search_library">Pesquisar na sua biblioteca…</string>
|
||||
<string name="stores_search_open">Pesquisar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">A carregar</string>
|
||||
<string name="stores_nothing_matches">Nenhum jogo</string>
|
||||
<string name="stores_shelves_loading">A carregar a loja…</string>
|
||||
@@ -1970,7 +1967,19 @@
|
||||
<string name="stores_view_on">Ver em %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">Não instalado</string>
|
||||
<string name="stores_in_library">Na biblioteca</string>
|
||||
<string name="stores_itch_key">Chave de API</string>
|
||||
<string name="stores_itch_get_key">Obter uma chave</string>
|
||||
<string name="stores_itch_key_rejected">Chave rejeitada</string>
|
||||
<string name="stores_itch_not_owned">Não está na sua biblioteca do itch.io</string>
|
||||
<string name="stores_itch_no_upload">Sem transferência para Windows deste jogo</string>
|
||||
<string name="stores_itch_no_exe">Não foi encontrado nenhum jogo para Windows na transferência</string>
|
||||
<string name="stores_itch_extract_failed">Não foi possível extrair a transferência (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">O itch.io não respondeu</string>
|
||||
<string name="stores_itch_archive">Ficheiros %1$s não são suportados</string>
|
||||
<string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string>
|
||||
<string name="stores_free">Grátis</string>
|
||||
<string name="stores_resume">Retomar</string>
|
||||
<string name="stores_refresh">Atualizar</string>
|
||||
@@ -1984,6 +1993,7 @@
|
||||
<string name="stores_sort_release_date">Data de lançamento</string>
|
||||
<string name="stores_view_list">Vista de lista</string>
|
||||
<string name="stores_view_cards">Vista de cartões</string>
|
||||
<string name="stores_view_capsules">Vista de capas</string>
|
||||
<string name="stores_show_mature">Mostrar conteúdo para adultos</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Geração de quadros</string>
|
||||
<string name="frame_gen_hint">Selecione o modo de geração de quadros</string>
|
||||
<string name="frame_gen_off">Desativada</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptativo %1$d</string>
|
||||
<string name="lsfg_needs_steam">Requer o Lossless Scaling na Steam</string>
|
||||
@@ -1936,7 +1936,6 @@
|
||||
<string name="stores_settings_eyebrow">Configurações</string>
|
||||
<string name="stores_chip_signed_out">(sem login)</string>
|
||||
<string name="stores_tab_store">Loja</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1948,10 +1947,8 @@
|
||||
<string name="stores_signout_stay">Continuar conectado</string>
|
||||
<string name="stores_signout_go">Sair</string>
|
||||
<string name="stores_search_catalog">Pesquisar no catálogo de %1$s…</string>
|
||||
<string name="stores_search_installed">Pesquisar jogos instalados…</string>
|
||||
<string name="stores_search_library">Pesquisar na sua biblioteca…</string>
|
||||
<string name="stores_search_open">Pesquisar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">Carregando</string>
|
||||
<string name="stores_nothing_matches">Nenhum jogo</string>
|
||||
<string name="stores_shelves_loading">Carregando a loja…</string>
|
||||
@@ -1970,7 +1967,19 @@
|
||||
<string name="stores_view_on">Ver em %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">Não instalado</string>
|
||||
<string name="stores_in_library">Na biblioteca</string>
|
||||
<string name="stores_itch_key">Chave de API</string>
|
||||
<string name="stores_itch_get_key">Obter uma chave</string>
|
||||
<string name="stores_itch_key_rejected">Chave rejeitada</string>
|
||||
<string name="stores_itch_not_owned">Não está na sua biblioteca do itch.io</string>
|
||||
<string name="stores_itch_no_upload">Sem download para Windows deste jogo</string>
|
||||
<string name="stores_itch_no_exe">Nenhum jogo para Windows encontrado no download</string>
|
||||
<string name="stores_itch_extract_failed">Não foi possível extrair o download (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">O itch.io não respondeu</string>
|
||||
<string name="stores_itch_archive">Arquivos %1$s não são suportados</string>
|
||||
<string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string>
|
||||
<string name="stores_free">Grátis</string>
|
||||
<string name="stores_resume">Retomar</string>
|
||||
<string name="stores_refresh">Atualizar</string>
|
||||
@@ -1984,6 +1993,7 @@
|
||||
<string name="stores_sort_release_date">Data de lançamento</string>
|
||||
<string name="stores_view_list">Vista de lista</string>
|
||||
<string name="stores_view_cards">Vista de cartões</string>
|
||||
<string name="stores_view_capsules">Visualização de capas</string>
|
||||
<string name="stores_show_mature">Mostrar conteúdo adulto</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string name="frame_gen_title">Generare de cadre</string>
|
||||
<string name="frame_gen_hint">Selectează modul de generare a cadrelor</string>
|
||||
<string name="frame_gen_off">Dezactivat</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_ddfg">DD-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptiv %1$d</string>
|
||||
<string name="lsfg_needs_steam">Necesită Lossless Scaling pe Steam</string>
|
||||
@@ -1947,7 +1947,6 @@
|
||||
<string name="stores_settings_eyebrow">Setări</string>
|
||||
<string name="stores_chip_signed_out">(neautentificat)</string>
|
||||
<string name="stores_tab_store">Magazin</string>
|
||||
<string name="stores_tab_installed">Instalate</string>
|
||||
<string name="stores_tab_library">Bibliotecă</string>
|
||||
<string name="stores_tab_all">Toate</string>
|
||||
<string name="stores_tab_downloads">Descărcări</string>
|
||||
@@ -1959,10 +1958,8 @@
|
||||
<string name="stores_signout_stay">Rămâi autentificat</string>
|
||||
<string name="stores_signout_go">Deconectare</string>
|
||||
<string name="stores_search_catalog">Caută în catalogul %1$s…</string>
|
||||
<string name="stores_search_installed">Caută jocurile instalate…</string>
|
||||
<string name="stores_search_library">Caută în biblioteca ta…</string>
|
||||
<string name="stores_search_open">Căutare</string>
|
||||
<string name="stores_installed_empty">Nimic instalat</string>
|
||||
<string name="stores_library_loading">Se încarcă</string>
|
||||
<string name="stores_nothing_matches">Niciun joc</string>
|
||||
<string name="stores_shelves_loading">Se încarcă magazinul…</string>
|
||||
@@ -1981,7 +1978,19 @@
|
||||
<string name="stores_view_on">Vezi pe %1$s</string>
|
||||
<string name="stores_buy_price">Cumpără · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalat</string>
|
||||
<string name="stores_section_installed">Instalat</string>
|
||||
<string name="stores_section_not_installed">Neinstalat</string>
|
||||
<string name="stores_in_library">În bibliotecă</string>
|
||||
<string name="stores_itch_key">Cheie API</string>
|
||||
<string name="stores_itch_get_key">Obține o cheie</string>
|
||||
<string name="stores_itch_key_rejected">Cheie respinsă</string>
|
||||
<string name="stores_itch_not_owned">Nu este în biblioteca ta itch.io</string>
|
||||
<string name="stores_itch_no_upload">Nu există descărcare pentru Windows la acest joc</string>
|
||||
<string name="stores_itch_no_exe">Nu s-a găsit niciun joc Windows în descărcare</string>
|
||||
<string name="stores_itch_extract_failed">Descărcarea nu a putut fi extrasă (cod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io nu a răspuns</string>
|
||||
<string name="stores_itch_archive">Arhivele %1$s nu sunt acceptate</string>
|
||||
<string name="stores_itch_download_failed">itch.io a răspuns HTTP %1$d</string>
|
||||
<string name="stores_free">Gratuit</string>
|
||||
<string name="stores_resume">Reluare</string>
|
||||
<string name="stores_refresh">Reîmprospătează</string>
|
||||
@@ -1995,6 +2004,7 @@
|
||||
<string name="stores_sort_release_date">Data lansării</string>
|
||||
<string name="stores_view_list">Vizualizare listă</string>
|
||||
<string name="stores_view_cards">Vizualizare carduri</string>
|
||||
<string name="stores_view_capsules">Vizualizare coperți</string>
|
||||
<string name="stores_show_mature">Arată conținutul pentru adulți</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user