mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-20 06:04:22 -04:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66e238378e | ||
|
|
9971d8bbf0 | ||
|
|
41807e320f | ||
|
|
d7727d64e5 | ||
|
|
f533e9c5a7 | ||
|
|
b506652c36 | ||
|
|
60fba082e0 | ||
|
|
975c76e3e8 | ||
|
|
a394dfd31b | ||
|
|
a2e555f4cc | ||
|
|
31d12a9f6c | ||
|
|
3820445561 | ||
|
|
75d7edd2c3 | ||
|
|
da8fe068d9 | ||
|
|
8f9ce19638 | ||
|
|
b74edb7e8d | ||
|
|
2503d69293 | ||
|
|
5e38e79c96 | ||
|
|
5e5ffee93e | ||
|
|
a7662ae13a | ||
|
|
a249e896f7 | ||
|
|
ca3743520e | ||
|
|
98822fdd27 | ||
|
|
8d0225932c | ||
|
|
40bca96db5 | ||
|
|
a2a9586b54 | ||
|
|
2fe6ba36c4 | ||
|
|
e4c84e6e24 | ||
|
|
10ef8c4bd6 | ||
|
|
8e01cfb2ef | ||
|
|
b25c4ea26b | ||
|
|
cebad5692d | ||
|
|
6b2c8ace09 | ||
|
|
4d1b8421fb | ||
|
|
ced4ac5db8 | ||
|
|
e190f57b53 | ||
|
|
22f02fb6bf | ||
|
|
52c67df4c7 | ||
|
|
de1a734fb5 | ||
|
|
be9939d82c | ||
|
|
e4c375ad0d | ||
|
|
0cafa83ea7 | ||
|
|
435beb7f19 | ||
|
|
78fcb60fd5 | ||
|
|
4995b3553b | ||
|
|
3c2878cc52 | ||
|
|
715032062d | ||
|
|
dde08414fc |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Vendored files preserve upstream license spacing and unified-diff context lines.
|
||||
tools/comet/LICENSE whitespace=-blank-at-eof
|
||||
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
|
||||
@@ -186,7 +186,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', '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/linuxfs/debian-image.env') }}
|
||||
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/**') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -218,6 +218,9 @@ jobs:
|
||||
mkdir -p "$D/usr/local/bin"
|
||||
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
|
||||
# Cardboard mode's SteamVR driver and Vulkan layer (tools/vr), Vulkan headers from the runner's NDK.
|
||||
VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
|
||||
tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr"
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
|
||||
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
|
||||
done
|
||||
@@ -450,6 +453,13 @@ jobs:
|
||||
restore-keys: |
|
||||
cxx-${{ env.NDK_VERSION }}-
|
||||
|
||||
- name: Build GOG Galaxy companion
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
rustup toolchain install 1.90.0 --profile minimal
|
||||
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
|
||||
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
env:
|
||||
@@ -457,6 +467,7 @@ jobs:
|
||||
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
python3 tools/release/runtime_inputs.py record
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
|
||||
@@ -617,10 +628,11 @@ jobs:
|
||||
|
||||
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
|
||||
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
|
||||
# Discord's builds channel linking to it. The release is made with a token of the org's
|
||||
# Discord's #preview-builds channel linking to it. The release is made with a token of the org's
|
||||
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
|
||||
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
|
||||
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
|
||||
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL. Keep this secret name when renaming
|
||||
# the channel: Discord routes the existing webhook by channel ID, not its display name.
|
||||
announce:
|
||||
needs: [build, sign]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
@@ -722,7 +734,7 @@ jobs:
|
||||
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
|
||||
echo "Published $url"
|
||||
|
||||
- name: Post to Discord
|
||||
- name: Post the Preview build to Discord
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -746,7 +758,7 @@ jobs:
|
||||
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
|
||||
fi
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
|
||||
content=$(printf '**New signed Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\n%s' "${GITHUB_SHA::7}" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
|
||||
@@ -40,3 +40,4 @@ __pycache__/
|
||||
# Local signing with the release key (tools/build_local.sh); holds a password.
|
||||
.signing.env
|
||||
app/src/main/assets/directaudio/
|
||||
/tools/vr/out/
|
||||
+8
-2
@@ -92,8 +92,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 12
|
||||
versionName "0.3.2"
|
||||
versionCode 13
|
||||
versionName "0.4.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -287,6 +287,9 @@ dependencies {
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
// @Preview for the composables; the tooling that renders them is a debug-only dependency.
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
// The File Manager, Bannerlator's: its icon set, image thumbnails and the coroutines it
|
||||
// copies on. Same versions as Bannerlator so the screen stays a straight copy.
|
||||
@@ -302,4 +305,7 @@ dependencies {
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
// The Android archive does not include the host natives used by JVM archive tests.
|
||||
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar'
|
||||
}
|
||||
@@ -3600,6 +3600,13 @@ static bool wait_snapshot(std::shared_ptr<FakeController> fake,
|
||||
}
|
||||
}
|
||||
|
||||
static int forward_ioctl(int fd, ioctl_request_t op, void *argp) {
|
||||
using ioctl_fn = int (*)(int, ioctl_request_t, ...);
|
||||
// Preserve later preload hooks, including GPU allocation adapters.
|
||||
static const auto next = reinterpret_cast<ioctl_fn>(dlsym(RTLD_NEXT, "ioctl"));
|
||||
return next ? next(fd, op, argp) : static_cast<int>(syscall(SYS_ioctl, fd, op, argp));
|
||||
}
|
||||
|
||||
EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
va_list va;
|
||||
void *argp;
|
||||
@@ -3611,7 +3618,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
// The lock must not be held across the passthrough: binder's transport is a
|
||||
// blocking ioctl(BINDER_WRITE_READ), so a parked binder pool thread would own
|
||||
// controller_mutex() for as long as it waits and deadlock every other ioctl.
|
||||
if (!watched(fd)) return syscall(SYS_ioctl, fd, op, argp);
|
||||
if (!watched(fd)) return forward_ioctl(fd, op, argp);
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
auto maker = uinput_map().find(fd);
|
||||
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
|
||||
@@ -3629,7 +3636,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
auto controller = controller_map().find(fd);
|
||||
if (controller == controller_map().end()) {
|
||||
guard.unlock();
|
||||
return syscall(SYS_ioctl, fd, op, argp);
|
||||
return forward_ioctl(fd, op, argp);
|
||||
}
|
||||
|
||||
int type = (op >> 8 & 0xFF);
|
||||
@@ -3825,7 +3832,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
} else {
|
||||
Logger::log("Unhandled evdev ioctl, type %d number %d\n", type, number);
|
||||
guard.unlock();
|
||||
return syscall(SYS_ioctl, fd, op, argp);
|
||||
return forward_ioctl(fd, op, argp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ add_library(droiddeckwayland SHARED
|
||||
src/compositor.c
|
||||
src/vk_present.c
|
||||
src/effects_chain.c
|
||||
src/cardboard.c
|
||||
src/vk_loader.c
|
||||
src/sc_layer.c
|
||||
src/ahb_swapchain.c
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
/* Cardboard pass - see cardboard.h. Compositor thread unless noted.
|
||||
*
|
||||
* The shader is cardboard.frag, pre-compiled like the rest of the compositor's SPIR-V:
|
||||
* glslangValidator -V --vn cardboard_frag_code -o cardboard_frag.h cardboard.frag
|
||||
*/
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "cardboard.h"
|
||||
#include <math.h>
|
||||
#include <pthread.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef CARDBOARD_HOST_TEST
|
||||
#include <fcntl.h>
|
||||
#include <stdatomic.h>
|
||||
#include <sys/mman.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include "headpose_shm.h"
|
||||
#endif
|
||||
|
||||
#ifndef CARDBOARD_HOST_TEST
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* droiddeck_log */
|
||||
/* upscale_vert.h defines its array non-static, and effects_chain.c includes it too: a private name here. */
|
||||
#define upscale_vert_code cardboard_upscale_vert_code
|
||||
#include "upscale_vert.h"
|
||||
#undef upscale_vert_code
|
||||
#include "cardboard_frag.h"
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ pure helpers */
|
||||
|
||||
static float clampf(float v, float lo, float hi) { return v < lo ? lo : v > hi ? hi : v; }
|
||||
|
||||
void cardboard_clamp(struct cardboard_params *p) {
|
||||
p->enabled = p->enabled ? 1 : 0;
|
||||
p->source = p->source == CARDBOARD_SOURCE_SBS ? CARDBOARD_SOURCE_SBS : CARDBOARD_SOURCE_SCREEN;
|
||||
p->ipd_m = clampf(p->ipd_m, 0.0f, 0.1f);
|
||||
p->screen_dist_m = clampf(p->screen_dist_m, 0.5f, 50.0f);
|
||||
p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f);
|
||||
p->k1 = clampf(p->k1, -1.0f, 2.0f);
|
||||
p->k2 = clampf(p->k2, -1.0f, 2.0f);
|
||||
p->fov_deg = clampf(p->fov_deg, 30.0f, 140.0f);
|
||||
p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f);
|
||||
p->chroma = clampf(p->chroma, 0.0f, 0.05f);
|
||||
}
|
||||
|
||||
void cardboard_push_constants(const struct cardboard_params *p, const float pose[9],
|
||||
int src_w, int src_h, int out_w, int out_h, float out[24]) {
|
||||
/* rot0..rot2: the pose's rows, each with one scalar riding in w. */
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 3; c++) out[r * 4 + c] = pose[r * 3 + c];
|
||||
out[3] = p->ipd_m * 0.5f;
|
||||
out[7] = p->screen_dist_m;
|
||||
out[11] = p->screen_width_m * 0.5f;
|
||||
/* lens */
|
||||
out[12] = p->k1;
|
||||
out[13] = p->k2;
|
||||
out[14] = tanf(p->fov_deg * 0.5f * (float)M_PI / 180.0f);
|
||||
out[15] = p->chroma;
|
||||
/* view */
|
||||
out[16] = p->lens_inset;
|
||||
out[17] = (src_w > 0 && src_h > 0) ? (float)src_w / (float)src_h : 16.0f / 9.0f;
|
||||
if (p->source == CARDBOARD_SOURCE_SBS) out[17] *= 0.5f; /* one eye's half */
|
||||
out[18] = (float)p->source;
|
||||
out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * 0.5f) / (float)out_h : 1.0f;
|
||||
out[20] = out[21] = out[22] = out[23] = 0.0f; /* reserved */
|
||||
}
|
||||
|
||||
#ifndef CARDBOARD_HOST_TEST
|
||||
|
||||
/* ------------------------------------------------------------------ settings + pose */
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct cardboard_params g_set = {
|
||||
.enabled = 0, .source = CARDBOARD_SOURCE_SCREEN, .ipd_m = 0.064f, .screen_dist_m = 3.0f,
|
||||
.screen_width_m = 4.0f, .k1 = 0.22f, .k2 = 0.24f, .fov_deg = 90.0f, .lens_inset = 0.0f, .chroma = 0.0f};
|
||||
static float g_pose[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
|
||||
static volatile int g_changed = 1;
|
||||
|
||||
static int g_live; /* the last pose came from a sensor */
|
||||
static void shm_publish(int live); /* below: the guest's copy of the pose */
|
||||
|
||||
void vkp_cardboard_set(const struct cardboard_params *p) {
|
||||
struct cardboard_params c = *p;
|
||||
cardboard_clamp(&c);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int was = g_set.enabled;
|
||||
g_set = c;
|
||||
g_changed = 1;
|
||||
shm_publish(g_live);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (was != c.enabled || c.enabled)
|
||||
droiddeck_log("cardboard", "%s: %s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f",
|
||||
c.enabled ? "on" : "off", c.source == CARDBOARD_SOURCE_SBS ? "side-by-side source" : "virtual screen",
|
||||
c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma);
|
||||
}
|
||||
|
||||
/* The guest's copy of the pose (headpose_shm.h), mapped once a path is known. */
|
||||
static struct droiddeck_headpose *g_shm;
|
||||
static int g_eye_w, g_eye_h; /* the last eye viewport drawn, for the guest */
|
||||
|
||||
void vkp_cardboard_set_pose_file(const char *path) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
if (!g_shm && path && *path) {
|
||||
int fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0600);
|
||||
if (fd >= 0 && ftruncate(fd, sizeof(struct droiddeck_headpose)) == 0) {
|
||||
void *p = mmap(NULL, sizeof(struct droiddeck_headpose), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (p != MAP_FAILED) {
|
||||
g_shm = p;
|
||||
memset(g_shm, 0, sizeof(*g_shm));
|
||||
g_shm->magic = DROIDDECK_HEADPOSE_MAGIC;
|
||||
g_shm->version = DROIDDECK_HEADPOSE_VERSION;
|
||||
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
|
||||
}
|
||||
}
|
||||
if (fd >= 0) close(fd);
|
||||
droiddeck_log("cardboard", g_shm ? "head pose shared with the guest at %s" : "could not share the head pose at %s", path);
|
||||
}
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
/* Under g_lock. */
|
||||
static void shm_publish(int live) {
|
||||
if (!g_shm) return;
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
atomic_uint *seq = (atomic_uint *)&g_shm->seq;
|
||||
unsigned s = atomic_load_explicit(seq, memory_order_relaxed);
|
||||
atomic_store_explicit(seq, s | 1u, memory_order_relaxed);
|
||||
atomic_thread_fence(memory_order_release);
|
||||
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
|
||||
g_shm->ipd_m = g_set.ipd_m;
|
||||
g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u);
|
||||
g_shm->time_ns = (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
|
||||
g_shm->tan_half_fov = tanf(g_set.fov_deg * 0.5f * (float)M_PI / 180.0f);
|
||||
g_shm->eye_w = (uint32_t)(g_eye_w > 0 ? g_eye_w : 0);
|
||||
g_shm->eye_h = (uint32_t)(g_eye_h > 0 ? g_eye_h : 0);
|
||||
g_shm->eye_aspect = g_eye_h > 0 ? (float)g_eye_w / (float)g_eye_h : 1.0f;
|
||||
atomic_thread_fence(memory_order_release);
|
||||
atomic_store_explicit(seq, (s | 1u) + 1u, memory_order_relaxed);
|
||||
}
|
||||
|
||||
|
||||
void vkp_cardboard_set_pose(const float m[9], int live) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
memcpy(g_pose, m, sizeof(g_pose));
|
||||
g_live = live;
|
||||
if (g_set.enabled && g_set.source == CARDBOARD_SOURCE_SCREEN) g_changed = 1;
|
||||
shm_publish(live);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
static struct cardboard_params g_cur;
|
||||
static float g_cur_pose[9];
|
||||
|
||||
static void snapshot(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_cur = g_set;
|
||||
memcpy(g_cur_pose, g_pose, sizeof(g_cur_pose));
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
int vkp_cardboard_active(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int on = g_set.enabled;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return on;
|
||||
}
|
||||
|
||||
int vkp_cardboard_pose_changed(void) {
|
||||
if (!g_changed) return 0;
|
||||
g_changed = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Vulkan objects */
|
||||
|
||||
#define CB_FORMAT VK_FORMAT_R8G8B8A8_UNORM
|
||||
#define CB_PC_SIZE (4 * 4 + 24 * 4) /* ndc + the 24 floats: 112 bytes, under the 128 guaranteed */
|
||||
#define CB_MAX_SOURCES 8 /* source views per frame: the real frame + generated ones */
|
||||
|
||||
static VkDevice g_dev;
|
||||
static VkPhysicalDeviceMemoryProperties g_mp;
|
||||
static int g_ready; /* 0 = not built, 1 = ok, -1 = failed (said once) */
|
||||
static VkRenderPass g_rp;
|
||||
static VkDescriptorSetLayout g_dsl;
|
||||
static VkPipelineLayout g_pl;
|
||||
static VkDescriptorPool g_dpool;
|
||||
static VkSampler g_sampler;
|
||||
static VkPipeline g_pipe;
|
||||
static struct { VkImage img; VkDeviceMemory mem; VkImageView view; VkFramebuffer fb; int w, h; } g_t;
|
||||
static struct { VkImageView view; VkDescriptorSet ds; } g_src[CB_MAX_SOURCES];
|
||||
static int g_nsrc;
|
||||
|
||||
void vkp_cardboard_bind_device(VkDevice dev, const VkPhysicalDeviceMemoryProperties *memprops) {
|
||||
g_dev = dev;
|
||||
g_mp = *memprops;
|
||||
}
|
||||
|
||||
static int memtype(uint32_t bits, VkMemoryPropertyFlags want) {
|
||||
for (uint32_t i = 0; i < g_mp.memoryTypeCount; i++)
|
||||
if ((bits & (1u << i)) && (g_mp.memoryTypes[i].propertyFlags & want) == want) return (int)i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void sources_release(void) {
|
||||
for (int i = 0; i < g_nsrc; i++) {
|
||||
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
|
||||
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
|
||||
}
|
||||
memset(g_src, 0, sizeof(g_src));
|
||||
g_nsrc = 0;
|
||||
}
|
||||
|
||||
void vkp_cardboard_begin_frame(void) {
|
||||
if (g_ready == 1) sources_release();
|
||||
}
|
||||
|
||||
static void target_destroy(void) {
|
||||
if (g_t.fb) g_vk.DestroyFramebuffer(g_dev, g_t.fb, NULL);
|
||||
if (g_t.view) g_vk.DestroyImageView(g_dev, g_t.view, NULL);
|
||||
if (g_t.img) g_vk.DestroyImage(g_dev, g_t.img, NULL);
|
||||
if (g_t.mem) g_vk.FreeMemory(g_dev, g_t.mem, NULL);
|
||||
memset(&g_t, 0, sizeof(g_t));
|
||||
}
|
||||
|
||||
static VkImageView make_view(VkImage img) {
|
||||
VkImageView v = VK_NULL_HANDLE;
|
||||
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
|
||||
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = CB_FORMAT,
|
||||
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
|
||||
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return v;
|
||||
}
|
||||
|
||||
/* The panel-sized output: rendered into, then blitted 1:1 to the swapchain. Kept uncompressed
|
||||
* (mutable format) like the effect chain's targets: UBWC render-then-blit targets corrupt on
|
||||
* Adreno 740. */
|
||||
static int target_ensure(int w, int h) {
|
||||
if (g_t.img && g_t.w == w && g_t.h == h) return 0;
|
||||
target_destroy();
|
||||
VkImageCreateInfo ii = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT,
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = CB_FORMAT, .extent = {(uint32_t)w, (uint32_t)h, 1},
|
||||
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.tiling = VK_IMAGE_TILING_OPTIMAL,
|
||||
.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
if (g_vk.CreateImage(g_dev, &ii, NULL, &g_t.img) != VK_SUCCESS) goto fail;
|
||||
VkMemoryRequirements req;
|
||||
g_vk.GetImageMemoryRequirements(g_dev, g_t.img, &req);
|
||||
int idx = memtype(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
if (idx < 0) idx = memtype(req.memoryTypeBits, 0);
|
||||
VkMemoryAllocateInfo mai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .allocationSize = req.size,
|
||||
.memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)};
|
||||
if (g_vk.AllocateMemory(g_dev, &mai, NULL, &g_t.mem) != VK_SUCCESS) goto fail;
|
||||
g_vk.BindImageMemory(g_dev, g_t.img, g_t.mem, 0);
|
||||
if (!(g_t.view = make_view(g_t.img))) goto fail;
|
||||
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = g_rp,
|
||||
.attachmentCount = 1, .pAttachments = &g_t.view, .width = (uint32_t)w,
|
||||
.height = (uint32_t)h, .layers = 1};
|
||||
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_t.fb) != VK_SUCCESS) goto fail;
|
||||
g_t.w = w;
|
||||
g_t.h = h;
|
||||
droiddeck_log("cardboard", "eye target %dx%d (two %dx%d eyes)", w, h, w / 2, h);
|
||||
return 0;
|
||||
fail:
|
||||
droiddeck_log("error", "cardboard: could not create the %dx%d eye target", w, h);
|
||||
target_destroy();
|
||||
return -1;
|
||||
}
|
||||
|
||||
static VkShaderModule shader(const uint32_t *code, size_t size) {
|
||||
VkShaderModule m = VK_NULL_HANDLE;
|
||||
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
|
||||
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return m;
|
||||
}
|
||||
|
||||
static void objects_destroy(void) {
|
||||
if (!g_dev) return;
|
||||
sources_release();
|
||||
target_destroy();
|
||||
if (g_pipe) g_vk.DestroyPipeline(g_dev, g_pipe, NULL);
|
||||
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
|
||||
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
|
||||
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
|
||||
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
|
||||
if (g_rp) g_vk.DestroyRenderPass(g_dev, g_rp, NULL);
|
||||
g_pipe = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
|
||||
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE; g_rp = VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
static int objects_ensure(void) {
|
||||
if (g_ready) return g_ready == 1 ? 0 : -1;
|
||||
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
|
||||
{
|
||||
/* One colour target, overwritten whole, left GENERAL for the 1:1 blit that follows. */
|
||||
VkAttachmentDescription att = {.format = CB_FORMAT, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
|
||||
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
|
||||
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
|
||||
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, .finalLayout = VK_IMAGE_LAYOUT_GENERAL};
|
||||
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
|
||||
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
|
||||
.pColorAttachments = &ref};
|
||||
VkSubpassDependency deps[2] = {
|
||||
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
||||
.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_TRANSFER_READ_BIT,
|
||||
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_SHADER_READ_BIT},
|
||||
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT}};
|
||||
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
|
||||
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
|
||||
.dependencyCount = 2, .pDependencies = deps};
|
||||
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &g_rp) != VK_SUCCESS) goto fail;
|
||||
|
||||
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
|
||||
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
|
||||
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
|
||||
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
|
||||
|
||||
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
|
||||
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
|
||||
.bindingCount = 1, .pBindings = &b};
|
||||
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
|
||||
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
|
||||
.offset = 0, .size = CB_PC_SIZE};
|
||||
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
|
||||
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
|
||||
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
|
||||
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, CB_MAX_SOURCES};
|
||||
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
|
||||
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT,
|
||||
.maxSets = CB_MAX_SOURCES, .poolSizeCount = 1, .pPoolSizes = &ps};
|
||||
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
|
||||
|
||||
VkShaderModule vert = shader(cardboard_upscale_vert_code, sizeof(cardboard_upscale_vert_code));
|
||||
VkShaderModule frag = shader(cardboard_frag_code, sizeof(cardboard_frag_code));
|
||||
if (!vert || !frag) {
|
||||
if (vert) g_vk.DestroyShaderModule(g_dev, vert, NULL);
|
||||
if (frag) g_vk.DestroyShaderModule(g_dev, frag, NULL);
|
||||
goto fail;
|
||||
}
|
||||
VkPipelineShaderStageCreateInfo st[2] = {
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT, .module = vert, .pName = "main"},
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT, .module = frag, .pName = "main"}};
|
||||
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
|
||||
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
|
||||
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
|
||||
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
|
||||
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
|
||||
.dynamicStateCount = 2, .pDynamicStates = dyn};
|
||||
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
|
||||
.viewportCount = 1, .scissorCount = 1};
|
||||
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
|
||||
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
|
||||
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
|
||||
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
|
||||
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
|
||||
VkPipelineColorBlendAttachmentState ba = {.blendEnable = VK_FALSE, .colorWriteMask = 0xF};
|
||||
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
|
||||
.attachmentCount = 1, .pAttachments = &ba};
|
||||
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
|
||||
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
|
||||
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
|
||||
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
|
||||
.renderPass = g_rp, .subpass = 0};
|
||||
VkResult r = g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &g_pipe);
|
||||
g_vk.DestroyShaderModule(g_dev, vert, NULL);
|
||||
g_vk.DestroyShaderModule(g_dev, frag, NULL);
|
||||
if (r != VK_SUCCESS) goto fail;
|
||||
}
|
||||
g_ready = 1;
|
||||
droiddeck_log("cardboard", "pass ready on %s", vkp_gpu_name());
|
||||
return 0;
|
||||
fail:
|
||||
objects_destroy();
|
||||
g_ready = -1;
|
||||
droiddeck_log("error", "cardboard: the pass could not be built on this driver; frames are shown flat");
|
||||
return -1;
|
||||
}
|
||||
|
||||
static VkDescriptorSet source_ds(VkImage src) {
|
||||
if (g_nsrc >= CB_MAX_SOURCES) return VK_NULL_HANDLE;
|
||||
VkImageView view = make_view(src);
|
||||
if (!view) return VK_NULL_HANDLE;
|
||||
VkDescriptorSet ds = VK_NULL_HANDLE;
|
||||
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
|
||||
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
|
||||
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &ds) != VK_SUCCESS) {
|
||||
g_vk.DestroyImageView(g_dev, view, NULL);
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = view, .imageLayout = VK_IMAGE_LAYOUT_GENERAL};
|
||||
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = ds, .dstBinding = 0,
|
||||
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.pImageInfo = &ii};
|
||||
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
|
||||
g_src[g_nsrc].view = view;
|
||||
g_src[g_nsrc].ds = ds;
|
||||
g_nsrc++;
|
||||
return ds;
|
||||
}
|
||||
|
||||
VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h, int out_w, int out_h) {
|
||||
if (out_w < 2 || out_h < 1 || objects_ensure() != 0 || target_ensure(out_w, out_h) != 0) return VK_NULL_HANDLE;
|
||||
VkDescriptorSet ds = source_ds(src);
|
||||
if (!ds) {
|
||||
static int said;
|
||||
if (!said) { said = 1; droiddeck_log("error", "cardboard: no descriptor for the frame; shown flat"); }
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
if (g_eye_w != out_w / 2 || g_eye_h != out_h) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_eye_w = out_w / 2;
|
||||
g_eye_h = out_h;
|
||||
shm_publish(g_live);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
snapshot();
|
||||
struct { float ndc[4]; float rest[24]; } pc = {{-1.0f, -1.0f, 1.0f, 1.0f}, {0}};
|
||||
cardboard_push_constants(&g_cur, g_cur_pose, src_w, src_h, out_w, out_h, pc.rest);
|
||||
|
||||
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_rp,
|
||||
.framebuffer = g_t.fb, .renderArea = {{0, 0}, {(uint32_t)out_w, (uint32_t)out_h}}};
|
||||
VkViewport vp = {0, 0, (float)out_w, (float)out_h, 0, 1};
|
||||
VkRect2D sc = {{0, 0}, {(uint32_t)out_w, (uint32_t)out_h}};
|
||||
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
|
||||
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
|
||||
g_vk.CmdSetScissor(cmd, 0, 1, &sc);
|
||||
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pipe);
|
||||
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
|
||||
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
|
||||
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
|
||||
g_vk.CmdEndRenderPass(cmd);
|
||||
return g_t.img;
|
||||
}
|
||||
|
||||
#endif /* CARDBOARD_HOST_TEST */
|
||||
@@ -0,0 +1,83 @@
|
||||
#version 450
|
||||
// Cardboard pass (cardboard.c): the finished frame -> two eye viewports, side by side, each
|
||||
// barrel-distorted for a phone-in-a-viewer lens. Runs on upscale.vert's full quad, so uv covers
|
||||
// the whole panel with (0,0) at the top left.
|
||||
//
|
||||
// Source modes:
|
||||
// 0 = virtual screen: the frame is a flat screen hanging in front of the viewer. Each eye casts
|
||||
// a ray through its lens, rotated by the head pose, from its own position (half the IPD to
|
||||
// either side), so the screen has real stereo depth and stays put while the head turns.
|
||||
// 1 = side by side: the frame already holds a left and a right eye image (SteamVR, an SBS 3D
|
||||
// game or video); each eye samples its own half. The head pose belongs to whoever rendered
|
||||
// it, so none is applied here.
|
||||
//
|
||||
// Regenerate cardboard_frag.h after editing (glslangValidator, see cardboard.c).
|
||||
layout(binding = 0) uniform sampler2D Source;
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
layout(push_constant) uniform PC {
|
||||
vec4 ndc; // upscale.vert's quad
|
||||
vec4 rot0; // xyz: head -> world rotation, row 0; w: half the IPD (m)
|
||||
vec4 rot1; // xyz: row 1; w: distance to the virtual screen (m)
|
||||
vec4 rot2; // xyz: row 2; w: half the virtual screen's width (m)
|
||||
vec4 lens; // x: k1, y: k2, z: tan of half the eye's vertical field of view, w: chromatic spread
|
||||
vec4 view; // x: lens centre inset (fraction of an eye viewport), y: source aspect w/h,
|
||||
// z: source mode, w: eye viewport aspect w/h
|
||||
} pc;
|
||||
|
||||
const vec3 BACKDROP = vec3(0.015, 0.015, 0.02);
|
||||
|
||||
// Lens: a point on the panel at tangent-space offset t (from the lens centre) shows the ray that
|
||||
// leaves the eye at t * (1 + k1 r^2 + k2 r^4). Sampling outwards like this draws the picture
|
||||
// barrel-shaped; the viewer's lens pincushions it back.
|
||||
vec2 undistort(vec2 t, float scale) {
|
||||
float r2 = dot(t, t);
|
||||
return t * scale * (1.0 + pc.lens.x * r2 + pc.lens.y * r2 * r2);
|
||||
}
|
||||
|
||||
// What one eye sees along tangent direction tan (x right, y down): colour, or the backdrop.
|
||||
vec3 eyeSample(vec2 tan, float eye) {
|
||||
if (pc.view.z > 0.5) {
|
||||
// Side by side: this eye's half of the source covers the eye's field of view.
|
||||
float tanH = pc.lens.z;
|
||||
vec2 s = vec2(tan.x / (tanH * pc.view.w), tan.y / tanH) * 0.5 + 0.5;
|
||||
if (any(lessThan(s, vec2(0.0))) || any(greaterThan(s, vec2(1.0)))) return vec3(0.0);
|
||||
return texture(Source, vec2((s.x + eye) * 0.5, s.y)).rgb;
|
||||
}
|
||||
// mat3() takes columns; the push constants carry rows.
|
||||
mat3 R = transpose(mat3(pc.rot0.xyz, pc.rot1.xyz, pc.rot2.xyz));
|
||||
vec3 d = R * normalize(vec3(tan.x, -tan.y, -1.0));
|
||||
vec3 o = R * vec3((eye < 0.5 ? -1.0 : 1.0) * pc.rot0.w, 0.0, 0.0);
|
||||
if (d.z > -1e-4) return BACKDROP; // looking away from the screen
|
||||
float t = (-pc.rot1.w - o.z) / d.z;
|
||||
vec3 hit = o + d * t;
|
||||
float halfW = pc.rot2.w, halfH = halfW / pc.view.y;
|
||||
vec2 s = vec2(hit.x / halfW, -hit.y / halfH) * 0.5 + 0.5;
|
||||
if (any(lessThan(s, vec2(0.0))) || any(greaterThan(s, vec2(1.0)))) return BACKDROP;
|
||||
return texture(Source, s).rgb;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float eye = uv.x < 0.5 ? 0.0 : 1.0;
|
||||
vec2 local = vec2(uv.x * 2.0 - eye, uv.y);
|
||||
// Lens centres sit inset towards the nose by view.x of a viewport.
|
||||
float cx = 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x);
|
||||
vec2 p = local - vec2(cx, 0.5);
|
||||
float tanH = pc.lens.z;
|
||||
vec2 t = vec2(p.x * pc.view.w, p.y) * 2.0 * tanH;
|
||||
|
||||
vec3 c;
|
||||
if (pc.lens.w > 0.0) {
|
||||
// Chromatic aberration: the lens bends red least and blue most; pre-spread to match.
|
||||
c.r = eyeSample(undistort(t, 1.0 - pc.lens.w), eye).r;
|
||||
c.g = eyeSample(undistort(t, 1.0), eye).g;
|
||||
c.b = eyeSample(undistort(t, 1.0 + pc.lens.w), eye).b;
|
||||
} else {
|
||||
c = eyeSample(undistort(t, 1.0), eye);
|
||||
}
|
||||
// Fade out towards each viewport's edge so the lens rim never shows a hard cut.
|
||||
vec2 e = min(local, 1.0 - local);
|
||||
float fade = smoothstep(0.0, 0.03, min(e.x * pc.view.w, e.y));
|
||||
outColor = vec4(c * fade, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#ifndef CARDBOARD_H
|
||||
#define CARDBOARD_H
|
||||
/*
|
||||
* Cardboard mode: the last stage of the compositor pass. The finished frame (after the effects and
|
||||
* frame generation) is drawn twice, once per eye, side by side on the panel, barrel-distorted for a
|
||||
* phone-in-a-viewer lens (cardboard.frag). The frame either hangs as a flat virtual screen in front
|
||||
* of the viewer, world-locked by the phone's rotation sensor (3DoF), or is already a side-by-side
|
||||
* stereo pair (SteamVR, SBS 3D content) that only gets the lens treatment.
|
||||
*
|
||||
* Present-pass order with Cardboard on (vk_present.c, render_impl):
|
||||
* scene -> effects -> frame generation -> vkp_cardboard_run (whole panel) -> 1:1 blit -> present
|
||||
* The Fullscreen Mode mapping does not apply: the pass fills the output itself.
|
||||
*
|
||||
* Settings and the head pose come from any thread (JNI, the sensor thread); the compositor thread
|
||||
* snapshots them per frame. A new pose redraws even a static scene (vkp_cardboard_pose_changed), so
|
||||
* the virtual screen stays put while the head turns.
|
||||
*/
|
||||
#define VK_USE_PLATFORM_ANDROID_KHR
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
enum { CARDBOARD_SOURCE_SCREEN = 0, CARDBOARD_SOURCE_SBS = 1 };
|
||||
|
||||
struct cardboard_params {
|
||||
int enabled;
|
||||
int source; /* CARDBOARD_SOURCE_* */
|
||||
float ipd_m; /* distance between the eyes, metres */
|
||||
float screen_dist_m; /* virtual screen distance, metres */
|
||||
float screen_width_m;/* virtual screen width, metres */
|
||||
float k1, k2; /* lens distortion coefficients */
|
||||
float fov_deg; /* vertical field of view of one eye before distortion */
|
||||
float lens_inset; /* lens centre inset towards the nose, fraction of an eye viewport */
|
||||
float chroma; /* chromatic aberration pre-correction (0 = off) */
|
||||
};
|
||||
|
||||
/* Any thread. Values are clamped to sane ranges. */
|
||||
void vkp_cardboard_set(const struct cardboard_params *p);
|
||||
/* Any thread: the head -> world rotation, row-major 3x3. Identity = looking straight at the screen.
|
||||
* live = a sensor produced it. Also published to the guest (headpose_shm.h) once a file is set. */
|
||||
void vkp_cardboard_set_pose(const float m[9], int live);
|
||||
/* Any thread, once: where the guest's copy of the pose lives (<files>/.wayland-rt/droiddeck-headpose). */
|
||||
void vkp_cardboard_set_pose_file(const char *path);
|
||||
|
||||
/* Compositor thread. */
|
||||
void vkp_cardboard_bind_device(VkDevice dev, const VkPhysicalDeviceMemoryProperties *memprops);
|
||||
/* 1 while Cardboard mode is on (the frame must take the compositor pass). */
|
||||
int vkp_cardboard_active(void);
|
||||
/* 1 when the settings or the pose changed since the last call: the caller redraws. */
|
||||
int vkp_cardboard_pose_changed(void);
|
||||
/* Called once per frame before any vkp_cardboard_run: releases the previous frame's source views
|
||||
* (that frame is fenced by now). */
|
||||
void vkp_cardboard_begin_frame(void);
|
||||
/* Records the pass into cmd: src (src_w x src_h, R8G8B8A8, in GENERAL) -> a panel-sized image
|
||||
* (out_w x out_h), returned in GENERAL. VK_NULL_HANDLE when the pass is unavailable (the caller
|
||||
* presents the frame the usual way). */
|
||||
VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h, int out_w, int out_h);
|
||||
|
||||
/* Pure helpers, shared with the host test (tools/tests). */
|
||||
void cardboard_clamp(struct cardboard_params *p);
|
||||
/* The 24 floats after the quad's ndc in the shader's push constants. */
|
||||
void cardboard_push_constants(const struct cardboard_params *p, const float pose[9],
|
||||
int src_w, int src_h, int out_w, int out_h, float out[24]);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,266 @@
|
||||
// 1116.2.0
|
||||
#pragma once
|
||||
const uint32_t cardboard_frag_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x0000016d,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000ee,0x00000165,0x00030010,
|
||||
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
|
||||
0x00000000,0x00070005,0x0000000d,0x69646e75,0x726f7473,0x66762874,0x31663b32,0x0000003b,
|
||||
0x00030005,0x0000000b,0x00000074,0x00040005,0x0000000c,0x6c616373,0x00000065,0x00070005,
|
||||
0x00000013,0x53657965,0x6c706d61,0x66762865,0x31663b32,0x0000003b,0x00030005,0x00000011,
|
||||
0x006e6174,0x00030005,0x00000012,0x00657965,0x00030005,0x00000015,0x00003272,0x00030005,
|
||||
0x0000001e,0x00004350,0x00040006,0x0000001e,0x00000000,0x0063646e,0x00050006,0x0000001e,
|
||||
0x00000001,0x30746f72,0x00000000,0x00050006,0x0000001e,0x00000002,0x31746f72,0x00000000,
|
||||
0x00050006,0x0000001e,0x00000003,0x32746f72,0x00000000,0x00050006,0x0000001e,0x00000004,
|
||||
0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00030005,
|
||||
0x00000020,0x00006370,0x00040005,0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,
|
||||
0x00000073,0x00040005,0x00000068,0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052,
|
||||
0x00030005,0x00000094,0x00000064,0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3,
|
||||
0x00000074,0x00030005,0x000000bd,0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057,
|
||||
0x00040005,0x000000c6,0x666c6168,0x00000048,0x00030005,0x000000cb,0x00000073,0x00030005,
|
||||
0x000000ec,0x00657965,0x00030005,0x000000ee,0x00007675,0x00040005,0x000000f4,0x61636f6c,
|
||||
0x0000006c,0x00030005,0x000000fe,0x00007863,0x00030005,0x0000010c,0x00000070,0x00040005,
|
||||
0x00000111,0x486e6174,0x00000000,0x00030005,0x00000114,0x00000074,0x00030005,0x00000125,
|
||||
0x00000063,0x00040005,0x00000129,0x61726170,0x0000006d,0x00040005,0x0000012b,0x61726170,
|
||||
0x0000006d,0x00040005,0x0000012d,0x61726170,0x0000006d,0x00040005,0x0000012e,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000133,0x61726170,0x0000006d,0x00040005,0x00000135,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000137,0x61726170,0x0000006d,0x00040005,0x00000138,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000140,0x61726170,0x0000006d,0x00040005,0x00000142,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000144,0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170,
|
||||
0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d,0x61726170,
|
||||
0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150,0x61726170,
|
||||
0x0000006d,0x00030005,0x00000153,0x00000065,0x00040005,0x00000159,0x65646166,0x00000000,
|
||||
0x00050005,0x00000165,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e,0x00000002,
|
||||
0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e,0x00000001,
|
||||
0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020,0x00050048,
|
||||
0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004,0x00000023,
|
||||
0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00040047,0x00000068,
|
||||
0x00000021,0x00000000,0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000ee,
|
||||
0x0000001e,0x00000000,0x00040047,0x00000165,0x0000001e,0x00000000,0x00020013,0x00000002,
|
||||
0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,
|
||||
0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009,
|
||||
0x00000007,0x00000006,0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017,
|
||||
0x0000000f,0x00000006,0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009,
|
||||
0x0004002b,0x00000006,0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004,
|
||||
0x0008001e,0x0000001e,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,
|
||||
0x00040020,0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009,
|
||||
0x00040015,0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004,
|
||||
0x00040015,0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000,
|
||||
0x00040020,0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001,
|
||||
0x0004002b,0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002,
|
||||
0x0004002b,0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023,
|
||||
0x00000046,0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007,
|
||||
0x00000055,0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c,
|
||||
0x00000007,0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054,
|
||||
0x00000054,0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000,
|
||||
0x00000000,0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067,
|
||||
0x00000000,0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075,
|
||||
0x0000000f,0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021,
|
||||
0x00000078,0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021,
|
||||
0x0000007d,0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093,
|
||||
0x00000007,0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006,
|
||||
0x000000ab,0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006,
|
||||
0x000000b0,0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0,
|
||||
0x00040020,0x000000ed,0x00000001,0x00000007,0x0004003b,0x000000ed,0x000000ee,0x00000001,
|
||||
0x00040020,0x000000ef,0x00000001,0x00000006,0x0004002b,0x00000006,0x000000f7,0x40000000,
|
||||
0x0004002b,0x00000006,0x0000015a,0x3cf5c28f,0x00040020,0x00000164,0x00000003,0x0000001d,
|
||||
0x0004003b,0x00000164,0x00000165,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,
|
||||
0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000009,0x000000ec,0x00000007,0x0004003b,
|
||||
0x00000008,0x000000f4,0x00000007,0x0004003b,0x00000009,0x000000fe,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000101,0x00000007,0x0004003b,0x00000008,0x0000010c,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000111,0x00000007,0x0004003b,0x00000008,0x00000114,0x00000007,0x0004003b,
|
||||
0x00000093,0x00000125,0x00000007,0x0004003b,0x00000008,0x00000129,0x00000007,0x0004003b,
|
||||
0x00000009,0x0000012b,0x00000007,0x0004003b,0x00000008,0x0000012d,0x00000007,0x0004003b,
|
||||
0x00000009,0x0000012e,0x00000007,0x0004003b,0x00000008,0x00000133,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000135,0x00000007,0x0004003b,0x00000008,0x00000137,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000138,0x00000007,0x0004003b,0x00000008,0x00000140,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000142,0x00000007,0x0004003b,0x00000008,0x00000144,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000145,0x00000007,0x0004003b,0x00000008,0x0000014b,0x00000007,0x0004003b,
|
||||
0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,0x0000014f,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008,0x00000153,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000159,0x00000007,0x00050041,0x000000ef,0x000000f0,0x000000ee,0x00000024,
|
||||
0x0004003d,0x00000006,0x000000f1,0x000000f0,0x000500b8,0x0000003b,0x000000f2,0x000000f1,
|
||||
0x0000003a,0x000600a9,0x00000006,0x000000f3,0x000000f2,0x00000054,0x0000001c,0x0003003e,
|
||||
0x000000ec,0x000000f3,0x00050041,0x000000ef,0x000000f5,0x000000ee,0x00000024,0x0004003d,
|
||||
0x00000006,0x000000f6,0x000000f5,0x00050085,0x00000006,0x000000f8,0x000000f6,0x000000f7,
|
||||
0x0004003d,0x00000006,0x000000f9,0x000000ec,0x00050083,0x00000006,0x000000fa,0x000000f8,
|
||||
0x000000f9,0x00050041,0x000000ef,0x000000fb,0x000000ee,0x0000002b,0x0004003d,0x00000006,
|
||||
0x000000fc,0x000000fb,0x00050050,0x00000007,0x000000fd,0x000000fa,0x000000fc,0x0003003e,
|
||||
0x000000f4,0x000000fd,0x0004003d,0x00000006,0x000000ff,0x000000ec,0x000500b8,0x0000003b,
|
||||
0x00000100,0x000000ff,0x0000003a,0x000300f7,0x00000103,0x00000000,0x000400fa,0x00000100,
|
||||
0x00000102,0x00000106,0x000200f8,0x00000102,0x00060041,0x00000025,0x00000104,0x00000020,
|
||||
0x00000036,0x00000024,0x0004003d,0x00000006,0x00000105,0x00000104,0x0003003e,0x00000101,
|
||||
0x00000105,0x000200f9,0x00000103,0x000200f8,0x00000106,0x00060041,0x00000025,0x00000107,
|
||||
0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x00000108,0x00000107,0x0004007f,
|
||||
0x00000006,0x00000109,0x00000108,0x0003003e,0x00000101,0x00000109,0x000200f9,0x00000103,
|
||||
0x000200f8,0x00000103,0x0004003d,0x00000006,0x0000010a,0x00000101,0x00050081,0x00000006,
|
||||
0x0000010b,0x0000003a,0x0000010a,0x0003003e,0x000000fe,0x0000010b,0x0004003d,0x00000007,
|
||||
0x0000010d,0x000000f4,0x0004003d,0x00000006,0x0000010e,0x000000fe,0x00050050,0x00000007,
|
||||
0x0000010f,0x0000010e,0x0000003a,0x00050083,0x00000007,0x00000110,0x0000010d,0x0000010f,
|
||||
0x0003003e,0x0000010c,0x00000110,0x00060041,0x00000025,0x00000112,0x00000020,0x00000022,
|
||||
0x00000037,0x0004003d,0x00000006,0x00000113,0x00000112,0x0003003e,0x00000111,0x00000113,
|
||||
0x00050041,0x00000009,0x00000115,0x0000010c,0x00000024,0x0004003d,0x00000006,0x00000116,
|
||||
0x00000115,0x00060041,0x00000025,0x00000117,0x00000020,0x00000036,0x00000046,0x0004003d,
|
||||
0x00000006,0x00000118,0x00000117,0x00050085,0x00000006,0x00000119,0x00000116,0x00000118,
|
||||
0x00050041,0x00000009,0x0000011a,0x0000010c,0x0000002b,0x0004003d,0x00000006,0x0000011b,
|
||||
0x0000011a,0x00050050,0x00000007,0x0000011c,0x00000119,0x0000011b,0x0005008e,0x00000007,
|
||||
0x0000011d,0x0000011c,0x000000f7,0x0004003d,0x00000006,0x0000011e,0x00000111,0x0005008e,
|
||||
0x00000007,0x0000011f,0x0000011d,0x0000011e,0x0003003e,0x00000114,0x0000011f,0x00060041,
|
||||
0x00000025,0x00000120,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000121,
|
||||
0x00000120,0x000500ba,0x0000003b,0x00000122,0x00000121,0x00000054,0x000300f7,0x00000124,
|
||||
0x00000000,0x000400fa,0x00000122,0x00000123,0x0000014a,0x000200f8,0x00000123,0x00060041,
|
||||
0x00000025,0x00000126,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000127,
|
||||
0x00000126,0x00050083,0x00000006,0x00000128,0x0000001c,0x00000127,0x0004003d,0x00000007,
|
||||
0x0000012a,0x00000114,0x0003003e,0x00000129,0x0000012a,0x0003003e,0x0000012b,0x00000128,
|
||||
0x00060039,0x00000007,0x0000012c,0x0000000d,0x00000129,0x0000012b,0x0003003e,0x0000012d,
|
||||
0x0000012c,0x0004003d,0x00000006,0x0000012f,0x000000ec,0x0003003e,0x0000012e,0x0000012f,
|
||||
0x00060039,0x0000000f,0x00000130,0x00000013,0x0000012d,0x0000012e,0x00050051,0x00000006,
|
||||
0x00000131,0x00000130,0x00000000,0x00050041,0x00000009,0x00000132,0x00000125,0x00000024,
|
||||
0x0003003e,0x00000132,0x00000131,0x0004003d,0x00000007,0x00000134,0x00000114,0x0003003e,
|
||||
0x00000133,0x00000134,0x0003003e,0x00000135,0x0000001c,0x00060039,0x00000007,0x00000136,
|
||||
0x0000000d,0x00000133,0x00000135,0x0003003e,0x00000137,0x00000136,0x0004003d,0x00000006,
|
||||
0x00000139,0x000000ec,0x0003003e,0x00000138,0x00000139,0x00060039,0x0000000f,0x0000013a,
|
||||
0x00000013,0x00000137,0x00000138,0x00050051,0x00000006,0x0000013b,0x0000013a,0x00000001,
|
||||
0x00050041,0x00000009,0x0000013c,0x00000125,0x0000002b,0x0003003e,0x0000013c,0x0000013b,
|
||||
0x00060041,0x00000025,0x0000013d,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,
|
||||
0x0000013e,0x0000013d,0x00050081,0x00000006,0x0000013f,0x0000001c,0x0000013e,0x0004003d,
|
||||
0x00000007,0x00000141,0x00000114,0x0003003e,0x00000140,0x00000141,0x0003003e,0x00000142,
|
||||
0x0000013f,0x00060039,0x00000007,0x00000143,0x0000000d,0x00000140,0x00000142,0x0003003e,
|
||||
0x00000144,0x00000143,0x0004003d,0x00000006,0x00000146,0x000000ec,0x0003003e,0x00000145,
|
||||
0x00000146,0x00060039,0x0000000f,0x00000147,0x00000013,0x00000144,0x00000145,0x00050051,
|
||||
0x00000006,0x00000148,0x00000147,0x00000002,0x00050041,0x00000009,0x00000149,0x00000125,
|
||||
0x00000037,0x0003003e,0x00000149,0x00000148,0x000200f9,0x00000124,0x000200f8,0x0000014a,
|
||||
0x0004003d,0x00000007,0x0000014c,0x00000114,0x0003003e,0x0000014b,0x0000014c,0x0003003e,
|
||||
0x0000014d,0x0000001c,0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d,
|
||||
0x0003003e,0x0000014f,0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000ec,0x0003003e,
|
||||
0x00000150,0x00000151,0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150,
|
||||
0x0003003e,0x00000125,0x00000152,0x000200f9,0x00000124,0x000200f8,0x00000124,0x0004003d,
|
||||
0x00000007,0x00000154,0x000000f4,0x0004003d,0x00000007,0x00000155,0x000000f4,0x00050050,
|
||||
0x00000007,0x00000156,0x0000001c,0x0000001c,0x00050083,0x00000007,0x00000157,0x00000156,
|
||||
0x00000155,0x0007000c,0x00000007,0x00000158,0x00000001,0x00000025,0x00000154,0x00000157,
|
||||
0x0003003e,0x00000153,0x00000158,0x00050041,0x00000009,0x0000015b,0x00000153,0x00000024,
|
||||
0x0004003d,0x00000006,0x0000015c,0x0000015b,0x00060041,0x00000025,0x0000015d,0x00000020,
|
||||
0x00000036,0x00000046,0x0004003d,0x00000006,0x0000015e,0x0000015d,0x00050085,0x00000006,
|
||||
0x0000015f,0x0000015c,0x0000015e,0x00050041,0x00000009,0x00000160,0x00000153,0x0000002b,
|
||||
0x0004003d,0x00000006,0x00000161,0x00000160,0x0007000c,0x00000006,0x00000162,0x00000001,
|
||||
0x00000025,0x0000015f,0x00000161,0x0008000c,0x00000006,0x00000163,0x00000001,0x00000031,
|
||||
0x00000054,0x0000015a,0x00000162,0x0003003e,0x00000159,0x00000163,0x0004003d,0x0000000f,
|
||||
0x00000166,0x00000125,0x0004003d,0x00000006,0x00000167,0x00000159,0x0005008e,0x0000000f,
|
||||
0x00000168,0x00000166,0x00000167,0x00050051,0x00000006,0x00000169,0x00000168,0x00000000,
|
||||
0x00050051,0x00000006,0x0000016a,0x00000168,0x00000001,0x00050051,0x00000006,0x0000016b,
|
||||
0x00000168,0x00000002,0x00070050,0x0000001d,0x0000016c,0x00000169,0x0000016a,0x0000016b,
|
||||
0x0000001c,0x0003003e,0x00000165,0x0000016c,0x000100fd,0x00010038,0x00050036,0x00000007,
|
||||
0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,0x0000000b,0x00030037,0x00000009,
|
||||
0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,0x00000015,0x00000007,0x0004003d,
|
||||
0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,0x00000017,0x0000000b,0x00050094,
|
||||
0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,0x00000015,0x00000018,0x0004003d,
|
||||
0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,0x0000001a,0x0000000c,0x0005008e,
|
||||
0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,0x00000025,0x00000026,0x00000020,
|
||||
0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,0x00000026,0x0004003d,0x00000006,
|
||||
0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,0x00000027,0x00000028,0x00050081,
|
||||
0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,0x00000025,0x0000002c,0x00000020,
|
||||
0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,0x0000002c,0x0004003d,0x00000006,
|
||||
0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,0x0000002d,0x0000002e,0x0004003d,
|
||||
0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,0x00000031,0x0000002f,0x00000030,
|
||||
0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,0x0005008e,0x00000007,0x00000033,
|
||||
0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,0x00050036,0x0000000f,0x00000013,
|
||||
0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,0x00030037,0x00000009,0x00000012,
|
||||
0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077,0x00000007,0x0004003b,0x00000093,
|
||||
0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f,0x00000007,0x0004003b,0x00000009,
|
||||
0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd,0x00000007,0x0004003b,0x00000009,
|
||||
0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6,0x00000007,0x0004003b,0x00000008,
|
||||
0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038,0x00000020,0x00000036,0x00000037,
|
||||
0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba,0x0000003b,0x0000003c,0x00000039,
|
||||
0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa,0x0000003c,0x0000003d,0x0000003e,
|
||||
0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040,0x00000020,0x00000022,0x00000037,
|
||||
0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e,0x0000003f,0x00000041,0x00050041,
|
||||
0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000044,0x00000043,
|
||||
0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041,0x00000025,0x00000047,0x00000020,
|
||||
0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048,0x00000047,0x00050085,0x00000006,
|
||||
0x00000049,0x00000045,0x00000048,0x00050088,0x00000006,0x0000004a,0x00000044,0x00000049,
|
||||
0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b,0x0004003d,0x00000006,0x0000004c,
|
||||
0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f,0x00050088,0x00000006,0x0000004e,
|
||||
0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f,0x0000004a,0x0000004e,0x0005008e,
|
||||
0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050,0x00000007,0x00000051,0x0000003a,
|
||||
0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050,0x00000051,0x0003003e,0x00000042,
|
||||
0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042,0x000500b8,0x00000056,0x00000057,
|
||||
0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058,0x00000057,0x000400a8,0x0000003b,
|
||||
0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000,0x000400fa,0x00000059,0x0000005a,
|
||||
0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007,0x0000005c,0x00000042,0x000500ba,
|
||||
0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a,0x0000003b,0x0000005f,0x0000005e,
|
||||
0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5,0x0000003b,0x00000060,0x00000058,
|
||||
0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062,0x00000000,0x000400fa,0x00000060,
|
||||
0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe,0x00000063,0x000200f8,0x00000062,
|
||||
0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041,0x00000009,0x0000006a,0x00000042,
|
||||
0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a,0x0004003d,0x00000006,0x0000006c,
|
||||
0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b,0x0000006c,0x00050085,0x00000006,
|
||||
0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009,0x0000006f,0x00000042,0x0000002b,
|
||||
0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050,0x00000007,0x00000071,0x0000006e,
|
||||
0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069,0x00000071,0x0008004f,0x0000000f,
|
||||
0x00000073,0x00000072,0x00000072,0x00000000,0x00000001,0x00000002,0x000200fe,0x00000073,
|
||||
0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a,0x00000020,0x00000078,0x0004003d,
|
||||
0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f,0x0000007c,0x0000007b,0x0000007b,
|
||||
0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x0000007e,0x00000020,0x0000007d,
|
||||
0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f,0x0000000f,0x00000080,0x0000007f,
|
||||
0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x00000082,0x00000020,
|
||||
0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082,0x0008004f,0x0000000f,0x00000084,
|
||||
0x00000083,0x00000083,0x00000000,0x00000001,0x00000002,0x00050051,0x00000006,0x00000085,
|
||||
0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086,0x0000007c,0x00000001,0x00050051,
|
||||
0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051,0x00000006,0x00000088,0x00000080,
|
||||
0x00000000,0x00050051,0x00000006,0x00000089,0x00000080,0x00000001,0x00050051,0x00000006,
|
||||
0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006,0x0000008b,0x00000084,0x00000000,
|
||||
0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001,0x00050051,0x00000006,0x0000008d,
|
||||
0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e,0x00000085,0x00000086,0x00000087,
|
||||
0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089,0x0000008a,0x00060050,0x0000000f,
|
||||
0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050,0x00000075,0x00000091,0x0000008e,
|
||||
0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092,0x00000091,0x0003003e,0x00000077,
|
||||
0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077,0x00050041,0x00000009,0x00000096,
|
||||
0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097,0x00000096,0x00050041,0x00000009,
|
||||
0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006,0x00000099,0x00000098,0x0004007f,
|
||||
0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f,0x0000009c,0x00000097,0x0000009a,
|
||||
0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001,0x00000045,0x0000009c,0x00050091,
|
||||
0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e,0x00000094,0x0000009e,0x0004003d,
|
||||
0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006,0x000000a1,0x00000012,0x000500b8,
|
||||
0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9,0x00000006,0x000000a3,0x000000a2,
|
||||
0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4,0x00000020,0x00000078,0x00000046,
|
||||
0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085,0x00000006,0x000000a6,0x000000a3,
|
||||
0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6,0x00000054,0x00000054,0x00050091,
|
||||
0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e,0x0000009f,0x000000a8,0x00050041,
|
||||
0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000aa,0x000000a9,
|
||||
0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab,0x000300f7,0x000000ae,0x00000000,
|
||||
0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8,0x000000ad,0x000200fe,0x000000b1,
|
||||
0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4,0x00000020,0x0000007d,0x00000046,
|
||||
0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f,0x00000006,0x000000b6,0x000000b5,
|
||||
0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037,0x0004003d,0x00000006,0x000000b8,
|
||||
0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6,0x000000b8,0x00050041,0x00000009,
|
||||
0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000bb,0x000000ba,0x00050088,
|
||||
0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e,0x000000b3,0x000000bc,0x0004003d,
|
||||
0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f,0x000000bf,0x00000094,0x0004003d,
|
||||
0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f,0x000000c1,0x000000bf,0x000000c0,
|
||||
0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1,0x0003003e,0x000000bd,0x000000c2,
|
||||
0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081,0x00000046,0x0004003d,0x00000006,
|
||||
0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5,0x0004003d,0x00000006,0x000000c7,
|
||||
0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020,0x00000036,0x0000002b,0x0004003d,
|
||||
0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006,0x000000ca,0x000000c7,0x000000c9,
|
||||
0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009,0x000000cc,0x000000bd,0x00000024,
|
||||
0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d,0x00000006,0x000000ce,0x000000c3,
|
||||
0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce,0x00050041,0x00000009,0x000000d0,
|
||||
0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1,0x000000d0,0x0004007f,0x00000006,
|
||||
0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3,0x000000c6,0x00050088,0x00000006,
|
||||
0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007,0x000000d5,0x000000cf,0x000000d4,
|
||||
0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a,0x00050050,0x00000007,0x000000d7,
|
||||
0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8,0x000000d6,0x000000d7,0x0003003e,
|
||||
0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9,0x000000cb,0x000500b8,0x00000056,
|
||||
0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b,0x000000db,0x000000da,0x000400a8,
|
||||
0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de,0x00000000,0x000400fa,0x000000dc,
|
||||
0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d,0x00000007,0x000000df,0x000000cb,
|
||||
0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d,0x0004009a,0x0000003b,0x000000e1,
|
||||
0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de,0x000700f5,0x0000003b,0x000000e2,
|
||||
0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7,0x000000e4,0x00000000,0x000400fa,
|
||||
0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3,0x000200fe,0x000000b1,0x000200f8,
|
||||
0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068,0x0004003d,0x00000007,0x000000e7,
|
||||
0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6,0x000000e7,0x0008004f,0x0000000f,
|
||||
0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001,0x00000002,0x000200fe,0x000000e9,
|
||||
0x00010038
|
||||
};
|
||||
@@ -50,6 +50,7 @@
|
||||
#include "framegen_bridge.h"
|
||||
#include <pthread.h>
|
||||
#include "effects_chain.h"
|
||||
#include "cardboard.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "compositor_internal.h"
|
||||
@@ -260,6 +261,7 @@ struct surface {
|
||||
/* Toplevel placement on the scene. */
|
||||
int mapped; /* in g_toplevels */
|
||||
struct wl_list toplevel_link;
|
||||
int keep_on_top; /* Cardboard mode's SteamVR window: above every other toplevel (raise_kept_on_top) */
|
||||
int x, y, placed;
|
||||
uint32_t hwnd;
|
||||
|
||||
@@ -291,6 +293,7 @@ static struct wl_event_source *g_render_idle, *g_frame_timer;
|
||||
* window) a fallback timer renders instead. */
|
||||
static int g_dirty; /* something changed since the last render */
|
||||
static int64_t g_last_vsync_ns; /* last tick, 0 = none yet */
|
||||
static int64_t g_tick_vsync_ns; /* the vsync that tick is for (Choreographer's frame time) */
|
||||
static int64_t g_refresh_ns = 16666667; /* measured tick interval (pacing only, never reported) */
|
||||
static struct wl_event_source *g_fallback_timer;
|
||||
static int g_fallback_armed;
|
||||
@@ -631,6 +634,8 @@ static struct surface *toplevel_by_hwnd(uint32_t hwnd) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void raise_kept_on_top(void);
|
||||
|
||||
/* Reorder the mapped toplevels to match the last reported Windows z-order. Windows the
|
||||
* report doesn't mention keep their relative order below the ones it does. */
|
||||
static void apply_zorder(void) {
|
||||
@@ -640,6 +645,17 @@ static void apply_zorder(void) {
|
||||
wl_list_remove(&s->toplevel_link);
|
||||
wl_list_insert(g_toplevels.prev, &s->toplevel_link);
|
||||
}
|
||||
raise_kept_on_top();
|
||||
}
|
||||
|
||||
/* Windows marked keep_on_top go back to the top of the stack (the end of g_toplevels). */
|
||||
static void raise_kept_on_top(void) {
|
||||
struct surface *s, *tmp, *kept[4];
|
||||
int n = 0;
|
||||
wl_list_for_each_safe(s, tmp, &g_toplevels, toplevel_link) {
|
||||
if (s->keep_on_top && n < 4) { wl_list_remove(&s->toplevel_link); kept[n++] = s; }
|
||||
}
|
||||
for (int i = 0; i < n; i++) wl_list_insert(g_toplevels.prev, &kept[i]->toplevel_link);
|
||||
}
|
||||
|
||||
static void map_toplevel(struct surface *s) {
|
||||
@@ -654,6 +670,7 @@ static void map_toplevel(struct surface *s) {
|
||||
s->placed ? "" : " (no desktop position yet)");
|
||||
}
|
||||
wl_list_insert(g_toplevels.prev, &s->toplevel_link); /* new windows start on top */
|
||||
raise_kept_on_top();
|
||||
/* Without a Wine desktop the clients are whole programs (the Linux desktop, a gamescope a
|
||||
* program on it started over it): a window that opens on top takes the keys too, as it would
|
||||
* on any desktop, rather than the one clicked last that is now underneath it. */
|
||||
@@ -763,8 +780,11 @@ static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resou
|
||||
/* The window the app's performance HUD follows: the latest one at least as big as the last to
|
||||
* start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */
|
||||
static struct surface *g_hud_surface;
|
||||
/* When the HUD's window last presented a frame (now_ns), to let a replacement take over. */
|
||||
static int64_t g_hud_last_ns;
|
||||
/* When the HUD's window last presented a new frame (now_ns), and when it last committed at all,
|
||||
* a repeat of the frame it shows included. A replacement may take over only once it has gone
|
||||
* quiet in the second sense: gamescope repeats a paused game's frame under a busy overlay, and
|
||||
* that overlay must not become the game. */
|
||||
static int64_t g_hud_last_ns, g_hud_alive_ns;
|
||||
/* That window committed a frame the screen has not drawn yet. */
|
||||
static int g_hud_fresh;
|
||||
extern void droiddeck_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
|
||||
@@ -774,7 +794,14 @@ extern void droiddeck_on_game_frame(void);
|
||||
extern void droiddeck_on_game_program(int pid, const char *program);
|
||||
|
||||
static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_resource *buffer) {
|
||||
if (s->dmabuf != buffer || s->dmabuf_buf != b) {
|
||||
/* The buffer this surface already shows, committed again. Gamescope's Wayland backend attaches
|
||||
* every plane on every frame it outputs, so its game plane comes back unchanged whenever only
|
||||
* something above it moved (MangoHud's graph, Steam's overlay). The surface still holds it and
|
||||
* never released it, so the client cannot have drawn into it: it is no new frame. Counted as
|
||||
* one, a game capped at 30 read 50-60 fps in the HUD, and frame generation took those repeats
|
||||
* as source frames ("LSFG 2x 59 -> 54" instead of 30 -> 60). */
|
||||
const int repeat = s->dmabuf == buffer && s->dmabuf_buf == b;
|
||||
if (!repeat) {
|
||||
drop_dmabuf(s, 1);
|
||||
s->dmabuf = buffer;
|
||||
s->dmabuf_buf = b;
|
||||
@@ -809,7 +836,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
s->buf_h = b->height;
|
||||
s->buf_alpha = (b->format & 0xff) == 'A';
|
||||
s->has_content = b->img != NULL;
|
||||
g_stat_dmabuf++;
|
||||
if (!repeat) g_stat_dmabuf++;
|
||||
const int announced_now = !s->announced_vulkan;
|
||||
if (!s->announced_vulkan) {
|
||||
char name[160];
|
||||
@@ -840,7 +867,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
* full-screen gamescope over the desktop still does), and a window gone quiet for a second
|
||||
* gives way to whatever draws next. */
|
||||
if (s != g_hud_surface && (b->img || ahb_swapchain_has_ahb(b)) &&
|
||||
(!g_hud_surface || now_ns() - g_hud_last_ns > 1000000000LL ||
|
||||
(!g_hud_surface || now_ns() - g_hud_alive_ns > 1000000000LL ||
|
||||
(announced_now && (int64_t)b->width * b->height >= (int64_t)g_hud_surface->buf_w * g_hud_surface->buf_h))) {
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
@@ -869,7 +896,13 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
* Asking whether the compositor could import it, as this used to, is a question about the
|
||||
* copy path and not about whether a frame happened: a game whose buffers go straight to
|
||||
* the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */
|
||||
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; droiddeck_on_game_frame(); }
|
||||
if (s == g_hud_surface) {
|
||||
g_hud_alive_ns = now_ns();
|
||||
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; droiddeck_on_game_frame();
|
||||
droiddeck_log("dd-source", "recv=%lld tick=%lld buffer=%u", (long long)g_hud_last_ns,
|
||||
(long long)g_tick_vsync_ns, wl_resource_get_id(buffer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- hooks for ahb_swapchain.c (zero-copy layers) */
|
||||
@@ -933,6 +966,20 @@ static uint32_t nominal_refresh_ns(void) {
|
||||
return (uint32_t)(1000000000000LL / (int64_t)mhz);
|
||||
}
|
||||
|
||||
/* The vblank a scene drawn at render_ns belongs to, for wp_presentation: the vsync of the tick that
|
||||
* drew it (Choreographer's frame time). Gamescope's Wayland backend anchors its vblank clock to this
|
||||
* and wakes a red zone before the next refresh. Reporting the end of the render instead (a few ms
|
||||
* past the tick) put its wakeups on top of the next tick: some frames missed it and showed twice,
|
||||
* others were replaced before they were shown (a 60 Hz Thor got ~50 distinct frames a second from
|
||||
* gamescope). Not the refresh after it either: gamescope then wakes a whole refresh late and runs
|
||||
* at half rate. Without a fresh tick (the fallback timer) it is the render time. */
|
||||
static int64_t presented_ns(int64_t render_ns) {
|
||||
if (!g_tick_vsync_ns || render_ns - g_last_vsync_ns > (int64_t)nominal_refresh_ns() ||
|
||||
render_ns < g_tick_vsync_ns)
|
||||
return render_ns;
|
||||
return g_tick_vsync_ns;
|
||||
}
|
||||
|
||||
static void feedback_present_all(struct wl_list *list, int64_t t) {
|
||||
struct wl_resource *fb, *tmp;
|
||||
uint64_t sec = (uint64_t)(t / 1000000000LL);
|
||||
@@ -1458,7 +1505,13 @@ static void xdg_toplevel_set_title(struct wl_client *c, struct wl_resource *r, c
|
||||
droiddeck_log("window", "renamed %s -> %s", before, after);
|
||||
}
|
||||
}
|
||||
static void xdg_toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *id) {}
|
||||
static void xdg_toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *id) {
|
||||
struct surface *s = wl_resource_get_user_data(r);
|
||||
/* Cardboard mode's SteamVR window (tools/vr/src/presenter.cpp) stays above every other window,
|
||||
* gamescope's included: gamescope reopens its window when Steam notices VR, and a reopened window
|
||||
* would otherwise come up over it (new windows start on top). */
|
||||
if (s && id && !strcmp(id, "droiddeck-cardboard-vr")) s->keep_on_top = 1;
|
||||
}
|
||||
static void xdg_toplevel_show_menu(struct wl_client *c, struct wl_resource *r, struct wl_resource *seat,
|
||||
uint32_t serial, int32_t x, int32_t y) {}
|
||||
static void xdg_toplevel_move(struct wl_client *c, struct wl_resource *r, struct wl_resource *seat,
|
||||
@@ -2071,6 +2124,8 @@ static void render_scene(void) {
|
||||
int rendered = 0;
|
||||
int fx_on = vkp_effects_active();
|
||||
int framegen = vkp_framegen_active();
|
||||
/* Cardboard mode draws the whole panel itself, so the game never gets a display layer of its own. */
|
||||
const int cardboard = vkp_cardboard_active();
|
||||
/* HDR (see hdr_plan): route 0 = the old paths below; 1 / 2 = composed; 3 = layer only, effects and
|
||||
* frame generation skipped. Always 0 while the HDR gate is closed: nothing changes for that session. */
|
||||
struct hdr_scene hs;
|
||||
@@ -2117,7 +2172,7 @@ static void render_scene(void) {
|
||||
* - screen effects DO when a window is drawn above the game: the chain has to see the whole
|
||||
* scene to look the way it does on the copy path, and here the game is only part of it. */
|
||||
const int pass_on = fx_on || framegen;
|
||||
int li = (g_zero_copy && !framegen) ? layer_candidate(&dl, w, h) : -1;
|
||||
int li = (g_zero_copy && !framegen && !cardboard) ? layer_candidate(&dl, w, h) : -1;
|
||||
int over = li >= 0 ? dl.n - 1 - li : 0; /* draws above the fullscreen game (0 or 1) */
|
||||
const int fx_blocks = (li >= 0 && fx_on && over > 0);
|
||||
if (fx_blocks) { li = -1; over = 0; }
|
||||
@@ -2147,6 +2202,7 @@ static void render_scene(void) {
|
||||
/* HDR: the same, for a game's SUBSURFACE swapchain whose 10-bit frame the compositor could not
|
||||
* import (NULL whenever the HDR gate is closed - nothing is ever noted then). */
|
||||
if (li < 0 && !ls) ls = hdr_unimported_fullscreen(&dl, w, h);
|
||||
if (cardboard) ls = NULL;
|
||||
if (ls && pass_on && li < 0 && !g_zero_copy_fx_skip_said) {
|
||||
g_zero_copy_fx_skip_said = 1;
|
||||
droiddeck_log(framegen ? "framegen" : "effects",
|
||||
@@ -2218,7 +2274,7 @@ static void render_scene(void) {
|
||||
free(hs.is_hdr);
|
||||
if (hdr_copy && rendered && droiddeck_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
|
||||
if (rendered) {
|
||||
int64_t t = now_ns();
|
||||
int64_t t = presented_ns(t_scene);
|
||||
g_stat_frames++;
|
||||
/* A surface outside the scene (role-less, not placed yet, a hidden helper window such
|
||||
* as wined3d's device window) was not shown, so its feedback is discarded rather than
|
||||
@@ -2283,12 +2339,15 @@ static void on_vsync(int64_t frame_time_ns) {
|
||||
if (d > 3000000LL && d < 40000000LL) g_refresh_ns = (g_refresh_ns * 7 + d) / 8;
|
||||
}
|
||||
g_last_vsync_ns = now;
|
||||
(void)frame_time_ns;
|
||||
g_tick_vsync_ns = frame_time_ns;
|
||||
droiddeck_log("dd-vtick", "frame=%lld dispatch=%lld", (long long)frame_time_ns, (long long)now);
|
||||
/* The app's surface may have been replaced or taken away since the last frame: apply that now
|
||||
* (the app never waits for us), and redraw the scene onto a new one. */
|
||||
if (vkp_apply_window_request()) g_dirty = 1;
|
||||
/* A screen-effect setting changed (JNI, any thread): redraw so it shows on a static scene too. */
|
||||
if (vkp_effects_sync()) g_dirty = 1;
|
||||
/* Cardboard mode: a new head pose (or setting) moves the virtual screen, static scene or not. */
|
||||
if (vkp_cardboard_pose_changed()) g_dirty = 1;
|
||||
if (g_dirty && !scene_waits_for_game(now)) render_scene();
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,11 @@ static void target_destroy(struct fx_target *t) {
|
||||
static int target_ensure(struct fx_target *t, int w, int h, const char *what) {
|
||||
if (t->img && t->w == w && t->h == h) return 0;
|
||||
target_destroy(t);
|
||||
VkImageCreateInfo ii = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
|
||||
/* Unrestricted mutable views keep these render-then-blit targets uncompressed on Turnip.
|
||||
* UBWC targets corrupt even a constant-colour pass on Adreno 740 (FSR becomes black).
|
||||
* Only the chain's intermediate images need this; the scene and guest images stay unchanged. */
|
||||
VkImageCreateInfo ii = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
|
||||
.flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT, .imageType = VK_IMAGE_TYPE_2D,
|
||||
.format = g_fx_fmt, .extent = {(uint32_t)w, (uint32_t)h, 1}, .mipLevels = 1,
|
||||
.arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_OPTIMAL,
|
||||
.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT |
|
||||
@@ -292,6 +296,10 @@ static void scene_release(void) {
|
||||
memset(&g_scene, 0, sizeof(g_scene));
|
||||
}
|
||||
|
||||
void vkp_effects_forget_image(VkImage img) {
|
||||
if (img && g_scene.img == img) scene_release();
|
||||
}
|
||||
|
||||
static int scene_ensure(VkImage img, int w, int h) {
|
||||
if (g_scene.img == img && g_scene.w == w && g_scene.h == h && g_scene.ds) return 0;
|
||||
scene_release();
|
||||
|
||||
@@ -55,6 +55,10 @@ VkImage vkp_effects_run(VkCommandBuffer cmd, VkImage scene, int scene_w, int sce
|
||||
/* Compositor thread: drop every Vulkan object (device teardown / device lost). */
|
||||
void vkp_effects_destroy(void);
|
||||
|
||||
/* Compositor thread: release the cached source view/descriptor before its owner destroys img.
|
||||
* A later image can reuse the same Vulkan handle and dimensions; that is not the same resource. */
|
||||
void vkp_effects_forget_image(VkImage img);
|
||||
|
||||
/* Compositor thread, before vkp_effects_run(): the format of the scene image it will be handed and the
|
||||
* format its own targets use. R8G8B8A8 / R8G8B8A8 for every SDR frame (the default); A2B10G10R10 /
|
||||
* A2B10G10R10 while an HDR picture runs through it (hdr_compose.h). A target-format change rebuilds the
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#ifndef HEADPOSE_SHM_H
|
||||
#define HEADPOSE_SHM_H
|
||||
/*
|
||||
* The head pose Cardboard mode tracks, shared with the guest: a 128-byte file the compositor maps
|
||||
* at <files>/.wayland-rt/droiddeck-headpose, which the guest sees as /run/droiddeck/droiddeck-headpose.
|
||||
* Written by the app (cardboard.c) at the sensor's rate; read by guest VR code (the SteamVR driver in
|
||||
* tools/vr) without any call into the app.
|
||||
*
|
||||
* Seqlock: the writer makes seq odd, writes the rest, then makes it even again. A reader copies the
|
||||
* struct and keeps the copy only if seq was even and unchanged across the copy.
|
||||
*
|
||||
* Axes are OpenVR's (and the cardboard shader's): x right, y up, z back (-z is straight ahead).
|
||||
* rot maps head directions to the recentred world, row-major. time_ns is CLOCK_MONOTONIC, the same
|
||||
* clock inside the guest (one kernel).
|
||||
*/
|
||||
#include <stdint.h>
|
||||
|
||||
#define DROIDDECK_HEADPOSE_MAGIC 0x50484444u /* "DDHP" */
|
||||
#define DROIDDECK_HEADPOSE_VERSION 1u
|
||||
#define DROIDDECK_HEADPOSE_FILE "droiddeck-headpose"
|
||||
|
||||
enum {
|
||||
DROIDDECK_HEADPOSE_LIVE = 1u << 0, /* a rotation sensor is feeding it (else identity) */
|
||||
DROIDDECK_HEADPOSE_ENABLED = 1u << 1, /* Cardboard mode is on */
|
||||
};
|
||||
|
||||
struct droiddeck_headpose {
|
||||
uint32_t magic;
|
||||
uint32_t version;
|
||||
uint32_t seq;
|
||||
uint32_t flags;
|
||||
float rot[9];
|
||||
float ipd_m;
|
||||
int64_t time_ns;
|
||||
/* What the lens pass expects of a side-by-side source, so a VR renderer can match it: one eye's
|
||||
* half of the frame covers tan_half_fov up and down and eye_aspect times that left and right. */
|
||||
float tan_half_fov;
|
||||
float eye_aspect;
|
||||
uint32_t eye_w, eye_h; /* one eye's viewport on the panel, pixels (0 = not drawn yet) */
|
||||
uint32_t reserved[12];
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
static_assert(sizeof(struct droiddeck_headpose) == 128, "headpose layout");
|
||||
#else
|
||||
_Static_assert(sizeof(struct droiddeck_headpose) == 128, "headpose layout");
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "vk_loader.h"
|
||||
#include "sc_layer.h"
|
||||
#include "effects_chain.h"
|
||||
#include "cardboard.h"
|
||||
#include "framegen_bridge.h"
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
@@ -541,6 +542,7 @@ static int dev_init(void) {
|
||||
g_vk.CreateFence(g_dev, &fci, NULL, &g_fence);
|
||||
|
||||
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
|
||||
vkp_cardboard_bind_device(g_dev, &g_memprops);
|
||||
hdrc_bind_device(g_dev, &g_memprops);
|
||||
blendp_bind_device(g_dev);
|
||||
g_dev_state = 1;
|
||||
@@ -1164,6 +1166,7 @@ static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkI
|
||||
}
|
||||
|
||||
static void destroy_scene_image(void) {
|
||||
if (g_scene.img) vkp_effects_forget_image(g_scene.img);
|
||||
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
|
||||
if (g_scene.img) blendp_forget_image(g_scene.img);
|
||||
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
|
||||
@@ -1203,6 +1206,7 @@ static int ensure_scene_image(int w, int h) {
|
||||
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
|
||||
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
|
||||
if (s->img) hdrc_forget_image(s->img);
|
||||
if (s->img) vkp_effects_forget_image(s->img);
|
||||
if (s->img) blendp_forget_image(s->img);
|
||||
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
|
||||
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
|
||||
@@ -1340,8 +1344,28 @@ static const char *vk_result_name(VkResult r) {
|
||||
* standing for the whole scene) through the scene -> output mapping, and leave the image ready
|
||||
* to present. */
|
||||
static void record_screen_blit(VkCommandBuffer cmd, VkImage src, int src_w, int src_h,
|
||||
int scene_w, int scene_h, uint32_t img, VkFilter filter) {
|
||||
int scene_w, int scene_h, uint32_t img, VkFilter filter, int cardboard) {
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
/* Cardboard mode (cardboard.h): the pass turns src into the two eye views over the whole panel,
|
||||
* and that image is blitted 1:1 instead of src going through the scene mapping. */
|
||||
int direct = 0;
|
||||
if (cardboard) {
|
||||
VkImageMemoryBarrier rd = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_GENERAL,
|
||||
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
|
||||
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||
.image = src, .subresourceRange = range,
|
||||
.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT, .dstAccessMask = VK_ACCESS_SHADER_READ_BIT};
|
||||
g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
||||
0, 0, NULL, 0, NULL, 1, &rd);
|
||||
VkImage eyes = vkp_cardboard_run(cmd, src, src_w, src_h, (int)g_extent.width, (int)g_extent.height);
|
||||
if (eyes) {
|
||||
src = eyes;
|
||||
src_w = (int)g_extent.width;
|
||||
src_h = (int)g_extent.height;
|
||||
direct = 1;
|
||||
}
|
||||
}
|
||||
VkImageMemoryBarrier pre[2] = {
|
||||
{.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED,
|
||||
.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
@@ -1359,7 +1383,12 @@ static void record_screen_blit(VkCommandBuffer cmd, VkImage src, int src_w, int
|
||||
struct vkp_image tmp = {.w = src_w, .h = src_h}; /* only its size is looked at */
|
||||
struct vkp_draw d = {&tmp, 0, 0, (float)src_w, (float)src_h, 0, 0, scene_w, scene_h};
|
||||
VkImageBlit blit;
|
||||
const int have_blit = draw_to_blit(&d, &blit);
|
||||
const int have_blit = direct ? 1 : draw_to_blit(&d, &blit);
|
||||
if (direct)
|
||||
blit = (VkImageBlit){.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
|
||||
.srcOffsets = {{0, 0, 0}, {src_w, src_h, 1}},
|
||||
.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
|
||||
.dstOffsets = {{0, 0, 0}, {src_w, src_h, 1}}};
|
||||
/* The result fills its whole mapped rectangle, so only the letterbox bars around it need black. */
|
||||
if (!have_blit || !record_bars_clear(cmd, g_images[img], &blit)) {
|
||||
VkClearColorValue black = {.float32 = {0.0f, 0.0f, 0.0f, 1.0f}};
|
||||
@@ -1569,6 +1598,15 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
* blit filter follows the scaling mode. */
|
||||
const int fx = !g_plain_frame && vkp_effects_active();
|
||||
const int fg = !g_plain_frame && vkp_framegen_active();
|
||||
/* Cardboard mode needs the whole frame too; it works on the 8-bit picture only (an HDR frame
|
||||
* is shown flat, said once). */
|
||||
int cb = !g_plain_frame && vkp_cardboard_active();
|
||||
if (cb && hf) {
|
||||
static int said;
|
||||
if (!said) { said = 1; droiddeck_log("cardboard", "HDR frames are shown flat: the cardboard pass is 8-bit only"); }
|
||||
cb = 0;
|
||||
}
|
||||
vkp_cardboard_begin_frame();
|
||||
/* HDR frames with frame generation through an HDR10 swapchain: the scene, the effects and the
|
||||
* engine's frames in FP16 (FG_HDR_FMT) where the engine can take that format - more than 8 bits of
|
||||
* PQ through interpolation - else the 8-bit scene, as before. */
|
||||
@@ -1592,7 +1630,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
}
|
||||
}
|
||||
/* An HDR scene always takes the pass: its draws are composed into one encoding first. */
|
||||
const int pass = deep || ((fx || fg || (hf && n > 0)) && ensure_scene_image(scene_w, scene_h) == 0);
|
||||
const int pass = deep || ((fx || fg || cb || (hf && n > 0)) && ensure_scene_image(scene_w, scene_h) == 0);
|
||||
VkImage scene_img = deep ? g_fgscene.img : g_scene.img;
|
||||
const VkFormat scene_fmt = deep ? FG_HDR_FMT : SCENE_FMT;
|
||||
const VkFilter blit_filter = vkp_effects_blit_filter();
|
||||
@@ -1744,7 +1782,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
}
|
||||
|
||||
/* 4. Mapping/blit: the first present carries generated frame 0, or the result itself. */
|
||||
record_screen_blit(cmd, ngen ? gens[0] : result, rw, rh, scene_w, scene_h, img, blit_filter);
|
||||
record_screen_blit(cmd, ngen ? gens[0] : result, rw, rh, scene_w, scene_h, img, blit_filter, cb);
|
||||
}
|
||||
g_vk.EndCommandBuffer(cmd);
|
||||
|
||||
@@ -1768,7 +1806,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkCommandBuffer ck = g_cmds[k];
|
||||
g_vk.ResetCommandBuffer(ck, 0);
|
||||
g_vk.BeginCommandBuffer(ck, &bi);
|
||||
record_screen_blit(ck, k < ngen ? gens[k] : result, rw, rh, scene_w, scene_h, img, blit_filter);
|
||||
record_screen_blit(ck, k < ngen ? gens[k] : result, rw, rh, scene_w, scene_h, img, blit_filter, cb);
|
||||
g_vk.EndCommandBuffer(ck);
|
||||
}
|
||||
const int last = (k + 1 == presents);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "ahb_swapchain.h"
|
||||
#include "sc_layer.h"
|
||||
#include "effects_chain.h"
|
||||
#include "cardboard.h"
|
||||
#include "droiddeck_color.h"
|
||||
|
||||
extern int droiddeck_wayland_run(void);
|
||||
@@ -463,6 +464,36 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI
|
||||
vkp_effects_set_screen(brightness, contrast, gamma, saturation, fxaa ? 1 : 0, toon ? 1 : 0, crt ? 1 : 0, ntsc ? 1 : 0);
|
||||
}
|
||||
|
||||
/* Cardboard mode - WaylandCompositor.nativeSetCardboard. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboard(JNIEnv *env, jclass clazz, jboolean enabled,
|
||||
jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma) {
|
||||
struct cardboard_params p = {.enabled = enabled ? 1 : 0, .source = source, .ipd_m = ipd, .screen_dist_m = dist,
|
||||
.screen_width_m = width, .k1 = k1, .k2 = k2, .fov_deg = fov, .lens_inset = inset,
|
||||
.chroma = chroma};
|
||||
vkp_cardboard_set(&p);
|
||||
}
|
||||
|
||||
/* The head pose (row-major 3x3, head -> world), from the sensor thread. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardPose(JNIEnv *env, jclass clazz, jfloatArray rot,
|
||||
jboolean live) {
|
||||
float m[9];
|
||||
if (!rot || (*env)->GetArrayLength(env, rot) < 9) return;
|
||||
(*env)->GetFloatArrayRegion(env, rot, 0, 9, m);
|
||||
vkp_cardboard_set_pose(m, live ? 1 : 0);
|
||||
}
|
||||
|
||||
/* Where the head pose is shared with the guest - WaylandCompositor.nativeSetCardboardPoseFile. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardPoseFile(JNIEnv *env, jclass clazz, jstring path) {
|
||||
if (!path) return;
|
||||
const char *p = (*env)->GetStringUTFChars(env, path, NULL);
|
||||
if (!p) return;
|
||||
vkp_cardboard_set_pose_file(p);
|
||||
(*env)->ReleaseStringUTFChars(env, path, p);
|
||||
}
|
||||
|
||||
/* One "display" line in the session log, written from Java: facts that live in the Android
|
||||
* framework (the panel's HDR capability) rather than in the compositor. droiddeck_log() mirrors to
|
||||
* logcat and appends to the session file when one is open, so this is safe at any point. */
|
||||
|
||||
@@ -22,6 +22,7 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
|
||||
@@ -9,10 +9,10 @@ import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.ProtonDefault
|
||||
|
||||
/**
|
||||
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
|
||||
* catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it.
|
||||
* Component state and work for the launcher and session drawer: the Protons and their
|
||||
* DXVK/VKD3D sets, the Nightlies catalog, downloads and the swap/restore actions.
|
||||
*/
|
||||
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: Handler) {
|
||||
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
|
||||
var compCatalogAt by mutableStateOf(0L)
|
||||
@@ -20,7 +20,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
|
||||
var compChecking by mutableStateOf(false)
|
||||
var compBusy by mutableStateOf<String?>(null)
|
||||
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
var compDownloads: Map<String, Int>
|
||||
get() = downloads
|
||||
private set(value) { downloads = value }
|
||||
val compDownloadRevision: Long get() = downloadRevision
|
||||
private var shownDefault: String? = null
|
||||
|
||||
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
|
||||
@@ -72,15 +75,15 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
}, "components-action").start()
|
||||
}
|
||||
|
||||
fun refreshComponentCatalog() {
|
||||
fun refreshComponentCatalog(force: Boolean = true) {
|
||||
if (compChecking) return
|
||||
compChecking = true
|
||||
Thread({
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, force) }.getOrNull()
|
||||
ui.post {
|
||||
compChecking = false
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
|
||||
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
if (cat != null && cat.items.isNotEmpty()) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
else if (force) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "components-catalog").start()
|
||||
}
|
||||
@@ -95,6 +98,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
}.getOrElse { e -> activity.getString(R.string.comp_download_failed, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post {
|
||||
compDownloads = compDownloads - item.file
|
||||
downloadRevision++
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshComponents()
|
||||
}
|
||||
@@ -102,6 +106,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MainActivity"
|
||||
const val TAG = "ComponentsMenu"
|
||||
// Both screens use the same package store and .part files. Keep the in-flight registry
|
||||
// across menu/activity instances so a package cannot be downloaded twice at once.
|
||||
var downloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
var downloadRevision by mutableStateOf(0L)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,6 @@ import com.droiddeck.launcher.session.GameSaves
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.ui.ProtonPage
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
@@ -55,7 +54,6 @@ import com.droiddeck.launcher.core.WifiDiscovery
|
||||
import com.droiddeck.launcher.core.WirelessAdbPairingService
|
||||
import com.droiddeck.launcher.ui.CoreRow
|
||||
import com.droiddeck.launcher.ui.PerformancePage
|
||||
import com.droiddeck.launcher.ui.ModeSettingsPage
|
||||
import com.droiddeck.launcher.ui.ModeSettings
|
||||
import com.droiddeck.launcher.ui.ModeSettingsActions
|
||||
import com.droiddeck.launcher.ui.ConfirmDialog
|
||||
@@ -124,7 +122,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
|
||||
private var showComponents by mutableStateOf(false)
|
||||
private var focusComponentsContent by mutableStateOf(true)
|
||||
@@ -166,8 +163,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
private var gameStoresEnabled by mutableStateOf(false)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
|
||||
@@ -338,7 +334,7 @@ class MainActivity : ComponentActivity() {
|
||||
romsDir = path
|
||||
}
|
||||
}
|
||||
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
|
||||
// The mode whose lifted sheet last read its settings, with what it shows; refreshed by loadModeSettings().
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
@@ -539,7 +535,6 @@ class MainActivity : ComponentActivity() {
|
||||
requestedOrientation = orientation
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
@@ -549,11 +544,8 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
showPerformance -> { { PerformanceHost() } }
|
||||
showProtons -> { { ProtonHost() } }
|
||||
showComponents -> { { ComponentsHost() } }
|
||||
showMapping -> { { MappingHost() } }
|
||||
else -> null
|
||||
@@ -572,7 +564,7 @@ class MainActivity : ComponentActivity() {
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
|
||||
appScale = appScale,
|
||||
@@ -597,7 +589,6 @@ class MainActivity : ComponentActivity() {
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
@@ -683,9 +674,10 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
// The activity re-attaches to the session that is running; nothing restarts.
|
||||
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
|
||||
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
|
||||
onSteamSettings = { loadModeSettings(SessionService.MODE_STEAM) },
|
||||
onRemoveDesktop = { removeDesktop() },
|
||||
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
|
||||
onDesktopSettings = { loadModeSettings(SessionService.MODE_DESKTOP) },
|
||||
modeSheet = { mode, tab, extras -> ModeSheetHost(mode, tab, extras) },
|
||||
onInstallPackage = { id -> installPackage(id) },
|
||||
onRemovePackage = { id -> removePackage(id) },
|
||||
onRuntime = { onRuntimeButton() },
|
||||
@@ -698,10 +690,11 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onProtons = { openProtons() },
|
||||
onComponents = { focusContent -> openComponents(focusContent) },
|
||||
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
|
||||
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
|
||||
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
|
||||
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
|
||||
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
|
||||
onPerformance = { refreshCores(); showComponents = false; showMapping = false; showPerformance = true },
|
||||
onRoms = { showRoms = true },
|
||||
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
|
||||
onBrowseFiles = { dir ->
|
||||
@@ -736,7 +729,8 @@ class MainActivity : ComponentActivity() {
|
||||
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
|
||||
offline = OfflineMode.enabled(this)
|
||||
},
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLanguage = { tag ->
|
||||
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
|
||||
@@ -759,8 +753,6 @@ class MainActivity : ComponentActivity() {
|
||||
launcherFullscreen = on
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
|
||||
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onNavigated = { navRequest = null },
|
||||
@@ -846,7 +838,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
|
||||
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
|
||||
),
|
||||
),
|
||||
@@ -966,14 +958,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
/** On the Components page the pad's LB / RB step through the stack's layers, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
|
||||
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
|
||||
components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -995,7 +985,7 @@ class MainActivity : ComponentActivity() {
|
||||
refreshWifiDiscovery()
|
||||
// Back from a store's sign-in page: the Stores chips show the account at once. A store
|
||||
// install finishing while this screen is up rebuilds the Games list through the listener.
|
||||
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
// Back from a session stopped behind a flood: open on its blue, before the first frame.
|
||||
@@ -1008,6 +998,8 @@ class MainActivity : ComponentActivity() {
|
||||
updates.onResume()
|
||||
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
|
||||
drivers.ensureAuto(force = false)
|
||||
// Newer GE-Proton / proton-cachyos / DroidDeck-Proton releases: looked up every few hours at most.
|
||||
protons.checkUpdates()
|
||||
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
|
||||
// session has just ended). Cheap when nothing is queued.
|
||||
if (!SessionState.running) Thread({
|
||||
@@ -1126,14 +1118,8 @@ class MainActivity : ComponentActivity() {
|
||||
}, "game-saves-action").start()
|
||||
}
|
||||
|
||||
private fun openProtons() {
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
showProtons = true
|
||||
protons.refreshProtons()
|
||||
}
|
||||
/** The Protons DroidDeck installs are the Components page's Proton layer. */
|
||||
private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB)
|
||||
|
||||
private fun refreshController() {
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
@@ -1153,13 +1139,19 @@ class MainActivity : ComponentActivity() {
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
|
||||
focusComponentsContent = focusContent
|
||||
if (tab != null) components.compComp = tab
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showMapping = false
|
||||
showComponents = true
|
||||
// The Proton layer's options for every Proton, as they are now.
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
components.refreshComponents(snapshotFirst = true)
|
||||
drivers.refreshDrivers()
|
||||
protons.refreshProtons()
|
||||
protons.checkUpdates()
|
||||
}
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
@@ -1193,7 +1185,11 @@ class MainActivity : ComponentActivity() {
|
||||
onDownload = { components.downloadComponent(it) },
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
|
||||
onBack = { showComponents = false },
|
||||
// Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came.
|
||||
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
|
||||
games = steamGames,
|
||||
generalFexPreset = fexPreset,
|
||||
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
gpu = drivers.state(),
|
||||
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
|
||||
onAuto = { on -> drivers.setMode(on) },
|
||||
@@ -1210,23 +1206,34 @@ class MainActivity : ComponentActivity() {
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
runtimeReady = ready && !busy, sessionRunning = SessionState.running,
|
||||
),
|
||||
protonActions = com.droiddeck.launcher.ui.ProtonsActions(
|
||||
onInstall = { id -> protons.installProton(id) },
|
||||
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
|
||||
onRemove = { id, dir -> protons.removeProton(id, dir) },
|
||||
onRemoveOlder = { id -> protons.removeProton(id, older = true) },
|
||||
),
|
||||
protonOptions = {
|
||||
com.droiddeck.launcher.ui.ProtonOptions(
|
||||
syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs,
|
||||
onSyncBackend = { id ->
|
||||
SessionPrefs.setSyncBackend(this, id)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtonHost() {
|
||||
ProtonPage(
|
||||
rows = protons.protonRows,
|
||||
busyId = protons.protonBusyId,
|
||||
stage = protons.protonStage,
|
||||
percent = protons.protonPercent,
|
||||
runtimeReady = ready && !busy,
|
||||
sessionRunning = SessionState.running,
|
||||
onInstall = { id -> protons.installProton(id) },
|
||||
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
|
||||
onRemove = { id -> protons.removeProton(id) },
|
||||
onBack = { showProtons = false },
|
||||
)
|
||||
// A download can finish in the session drawer while this page is visible.
|
||||
androidx.compose.runtime.LaunchedEffect(components.compDownloadRevision) { components.refreshComponents() }
|
||||
// A Proton installed or removed changes the stack's list of Protons.
|
||||
androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() }
|
||||
}
|
||||
|
||||
private fun refreshPackages() {
|
||||
@@ -1273,8 +1280,10 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeSettingsHost(mode: String) {
|
||||
ModeSettingsPage(
|
||||
private fun ModeSheetHost(mode: String, tab: String, extras: com.droiddeck.launcher.ui.SheetExtras) {
|
||||
// Read for the other mode last (or not yet): what this one shows is read as it opens.
|
||||
if (settingsMode != mode) return
|
||||
com.droiddeck.launcher.ui.ModeSheetRows(
|
||||
ModeSettings(
|
||||
mode = mode, resolution = resolution,
|
||||
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
|
||||
@@ -1394,8 +1403,9 @@ class MainActivity : ComponentActivity() {
|
||||
onPickDeckyPluginZip = {
|
||||
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
|
||||
},
|
||||
onDismiss = { settingsMode = null },
|
||||
onDismiss = {},
|
||||
),
|
||||
tab, extras,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1496,13 +1506,9 @@ class MainActivity : ComponentActivity() {
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
|
||||
private fun openModeSettings(mode: String) {
|
||||
private fun loadModeSettings(mode: String) {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
@@ -1530,8 +1536,8 @@ class MainActivity : ComponentActivity() {
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
settingsMode = mode
|
||||
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
|
||||
// added-games folders, the storage volumes) lands while it animates in.
|
||||
// The sheet lifts at once, on what was last read; the slow part (driver files, a walk of the
|
||||
// added-games folders, the storage volumes) lands while it rises.
|
||||
Thread({
|
||||
drivers.refreshDrivers()
|
||||
val storage = GameStorage.options(this)
|
||||
@@ -1607,7 +1613,10 @@ class MainActivity : ComponentActivity() {
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
// Steam's own games first, from their manifests alone (quick), beside the added games
|
||||
// The emulators first: they need no walk of the games, and Desktop's dock is made of them.
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
|
||||
// Steam's own games next, from their manifests alone (quick), beside the added games
|
||||
// already listed: a game Steam installed during the session shows as the session ends,
|
||||
// without waiting for the added games' walk below. Its Windows components' list is
|
||||
// queued then too, on the low-priority worker, so its page is usually ready.
|
||||
@@ -1628,7 +1637,6 @@ class MainActivity : ComponentActivity() {
|
||||
// so a partial list first would shuffle every capsule when the rest arrived.
|
||||
// LibraryCache covers the wait.
|
||||
val games = if (ready) Library.launchableGames(this) else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
// Once per app start: the Steam games' Windows components from Steam's lists.
|
||||
@@ -1643,7 +1651,6 @@ class MainActivity : ComponentActivity() {
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
emulatorList = emus
|
||||
shortcutLibraryScanning = false
|
||||
handleGameLink(all)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,16 @@ import com.droiddeck.launcher.ui.ProtonRow
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
/**
|
||||
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
|
||||
* removal in progress. The launcher screen keeps one and shows it.
|
||||
* The optional ARM64 Protons (GE-Proton, proton-cachyos, DroidDeck-Proton): the rows the page lists,
|
||||
* an install or removal in progress, and whether a newer release is out. The launcher screen keeps
|
||||
* one and shows it.
|
||||
*/
|
||||
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
|
||||
var protonBusyId by mutableStateOf<String?>(null)
|
||||
var protonStage by mutableStateOf<String?>(null)
|
||||
var protonPercent by mutableIntStateOf(-1)
|
||||
private val checking = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
fun installProton(id: String) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
@@ -40,14 +42,15 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
}, "install-proton-$id").start()
|
||||
}
|
||||
|
||||
fun removeProton(id: String) {
|
||||
/** Removes the build in [dir], or with [older] every build of it but the newest. */
|
||||
fun removeProton(id: String, dir: String? = null, older: Boolean = false) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
protonBusyId = id
|
||||
protonStage = activity.getString(R.string.main_removing_named, tool.name)
|
||||
protonStage = activity.getString(R.string.main_removing_named, dir ?: tool.name)
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
ProtonExtras.remove(activity, tool)
|
||||
if (older) ProtonExtras.removeOlder(activity, tool) else ProtonExtras.remove(activity, tool, dir)
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
@@ -57,6 +60,18 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
}
|
||||
|
||||
fun refreshProtons() {
|
||||
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
|
||||
protonRows = ProtonExtras.tools.map {
|
||||
val all = ProtonExtras.installedAll(activity, it)
|
||||
ProtonRow(it.id, it.name, all.lastOrNull(), ProtonExtras.queued(activity, it), all, ProtonExtras.update(activity, it))
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks for newer releases, at most every few hours (see ProtonExtras.checkLatest); quiet offline. */
|
||||
fun checkUpdates(force: Boolean = false) {
|
||||
if (!checking.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) }
|
||||
if (asked) ui.post { refreshProtons() }
|
||||
}, "proton-updates").start()
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,12 @@ import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
@@ -40,7 +42,17 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.agent.AgentGuest
|
||||
import com.droiddeck.launcher.frontend.LibraryCache
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreLaunchRequests
|
||||
import com.droiddeck.launcher.ui.SessionAchievements
|
||||
import com.droiddeck.launcher.gpu.Cardboard
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.HeadTracker
|
||||
import com.droiddeck.launcher.gpu.SteamVr
|
||||
import com.droiddeck.launcher.gpu.ScreenEffectLooks
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
@@ -64,6 +76,9 @@ import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.GameProfileFollowState
|
||||
import com.droiddeck.launcher.session.GameProfileInventoryCache
|
||||
import com.droiddeck.launcher.session.GameProfileRefreshState
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
@@ -73,6 +88,9 @@ import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.wayland.HdrSupport
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.ProtonDefault
|
||||
import com.droiddeck.launcher.session.SelectedGameProfile
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.ui.CursorOverlay
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import com.droiddeck.launcher.ui.DrawerActions
|
||||
@@ -86,6 +104,7 @@ import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.LatestRequestCoordinator
|
||||
import com.droiddeck.launcher.ui.SessionDrawerPage
|
||||
import com.droiddeck.launcher.ui.sessionDrawerPages
|
||||
import com.droiddeck.launcher.ui.step
|
||||
@@ -247,6 +266,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var closeAtOnce = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) }
|
||||
private var drawerGameProfileId by mutableStateOf<Long?>(null)
|
||||
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
|
||||
private var drawerGameInventoryRevision = 0L
|
||||
private var drawerGameFexPresetSaving by mutableStateOf(false)
|
||||
private var drawerGameNoSteamSaving by mutableStateOf(false)
|
||||
private val drawerGameProfileFollowState = GameProfileFollowState()
|
||||
private data class DrawerGameProfileRefresh(
|
||||
val followsSteam: Boolean,
|
||||
val requestedAppId: Long?,
|
||||
val inventoryRevision: Long,
|
||||
)
|
||||
private data class DrawerGameProfileResult(
|
||||
val profiles: List<Pair<Long, String>>,
|
||||
val selectedAppId: Long?,
|
||||
val profile: SelectedGameProfile?,
|
||||
val error: Exception? = null,
|
||||
)
|
||||
private var drawerGameProfileRefresh by mutableStateOf(GameProfileRefreshState<DrawerGameProfileResult>())
|
||||
private val drawerGameProfileRefreshes = LatestRequestCoordinator<DrawerGameProfileRefresh>()
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
@@ -254,12 +293,36 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
/** The game in front, when its source has achievements. */
|
||||
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
|
||||
private var achievementsOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
private var effects by mutableStateOf(ScreenEffects.OFF)
|
||||
/** Cardboard mode (gpu/Cardboard) and the sensor that turns the virtual screen with the head. */
|
||||
private var cardboard by mutableStateOf(Cardboard.OFF)
|
||||
private var headTracker: HeadTracker? = null
|
||||
private var steamVrStatus by mutableStateOf<SteamVr.Status?>(null)
|
||||
/** The picture was switched to side-by-side for SteamVR and goes back when it stops. */
|
||||
private var steamVrSwitchedPicture by mutableStateOf(false)
|
||||
/** SteamVR has been seen running since the switch: only then does "not running" mean it stopped. */
|
||||
private var steamVrSeenRunning = false
|
||||
private val hasRotationSensor by lazy {
|
||||
(getSystemService(SENSOR_SERVICE) as android.hardware.SensorManager).let {
|
||||
it.getDefaultSensor(android.hardware.Sensor.TYPE_GAME_ROTATION_VECTOR)
|
||||
?: it.getDefaultSensor(android.hardware.Sensor.TYPE_ROTATION_VECTOR)
|
||||
} != null
|
||||
}
|
||||
// Held in a field: SharedPreferences keeps its listeners weakly. A change from anywhere (the
|
||||
// drawer, tools/droiddeckctl prefs set) applies live.
|
||||
private val cardboardPrefListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == SessionPrefs.CARDBOARD_KEY) uiHandler.post { applyCardboard(SessionPrefs.cardboard(this)) }
|
||||
}
|
||||
private var textureAnisotropy by mutableStateOf(0)
|
||||
private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF)
|
||||
private var drawerTextureAnisotropy by mutableStateOf<Int?>(null)
|
||||
private var drawerTextureLodBias by mutableStateOf<String?>(null)
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
@@ -310,6 +373,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
padBridge?.syncConnectedControllers()
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
@@ -326,7 +390,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
runtimeRemovalBlocked = true
|
||||
@@ -466,6 +529,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
when {
|
||||
::loading.isInitialized && loading.ended -> finish()
|
||||
drawerOpen -> drawerOpen = false
|
||||
achievementsOpen -> closeAchievements()
|
||||
pcKeyboardOpen -> pcKeyboardOpen = false
|
||||
else -> routeBackAction()
|
||||
}
|
||||
@@ -486,7 +550,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
// The drawer, the PC keyboard and picture in picture take the mouse back.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
|
||||
@@ -529,6 +593,27 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
}
|
||||
// SteamVR's state for the Cardboard group, refreshed while the drawer shows it.
|
||||
// Watched for as long as Cardboard mode is on (or the picture is switched for it), drawer open or
|
||||
// not: however SteamVR is started or quit, the picture follows it.
|
||||
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture) {
|
||||
while (cardboard.enabled || steamVrSwitchedPicture) {
|
||||
val now = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
SteamVr.status(this@SessionActivity)
|
||||
}
|
||||
onSteamVrStatus(now)
|
||||
kotlinx.coroutines.delay(3000)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(drawerOpen, achievementsOpen, AgentGuest.focusRevision, StoreAchievements.revision) {
|
||||
if (!drawerOpen && !achievementsOpen) return@LaunchedEffect
|
||||
val found = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
runCatching { resolveAchievementsInFront() }
|
||||
.onFailure { Log.w(TAG, "achievements in front: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
}
|
||||
achievementsInFront = found
|
||||
if (found == null) achievementsOpen = false
|
||||
}
|
||||
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
|
||||
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
|
||||
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
|
||||
@@ -544,7 +629,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
|
||||
effects = effects,
|
||||
cardboard = cardboard,
|
||||
cardboardSensor = hasRotationSensor,
|
||||
onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) },
|
||||
onCardboardRecenter = { headTracker?.recenter() },
|
||||
steamVr = steamVrStatus,
|
||||
onSteamVr = { action -> steamVrAction(action) },
|
||||
textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy,
|
||||
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
@@ -569,8 +662,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e
|
||||
e.push(upscaler)
|
||||
},
|
||||
onTextureAnisotropy = { v -> SessionPrefs.setTextureAnisotropy(this@SessionActivity, v); textureAnisotropy = v },
|
||||
onTextureLodBias = { v -> SessionPrefs.setTextureLodBias(this@SessionActivity, v); textureLodBias = v },
|
||||
onTextureAnisotropy = { value ->
|
||||
drawerGameProfileId?.let { appId ->
|
||||
SessionPrefs.setGameTextureAnisotropy(this@SessionActivity, appId.toString(), value)
|
||||
drawerTextureAnisotropy = value
|
||||
} ?: value?.let {
|
||||
SessionPrefs.setTextureAnisotropy(this@SessionActivity, it)
|
||||
textureAnisotropy = it
|
||||
}
|
||||
},
|
||||
onTextureLodBias = { value ->
|
||||
drawerGameProfileId?.let { appId ->
|
||||
SessionPrefs.setGameTextureLodBias(this@SessionActivity, appId.toString(), value)
|
||||
drawerTextureLodBias = value
|
||||
} ?: value?.let {
|
||||
SessionPrefs.setTextureLodBias(this@SessionActivity, it)
|
||||
textureLodBias = it
|
||||
}
|
||||
},
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this@SessionActivity, mode)
|
||||
readPrefs()
|
||||
@@ -589,6 +698,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
|
||||
onAchievements = achievementsInFront?.let { { drawerOpen = false; achievementsOpen = true } },
|
||||
achievementsProgress = achievementsInFront?.progress,
|
||||
// Only where the session set the pad up to be switched (SessionService: deckPad).
|
||||
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
|
||||
onPadType = { id ->
|
||||
@@ -665,9 +776,48 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
},
|
||||
onClose = { drawerOpen = false },
|
||||
components = drawerComponents,
|
||||
onComponentsRefresh = { refreshDrawerComponents() },
|
||||
onComponentsRefresh = {
|
||||
refreshDrawerComponents()
|
||||
drawerComponentMenu.refreshComponentCatalog(force = false)
|
||||
},
|
||||
componentCatalog = drawerComponentMenu.compCatalog,
|
||||
componentCatalogAt = drawerComponentMenu.compCatalogAt,
|
||||
componentCatalogChecking = drawerComponentMenu.compChecking,
|
||||
componentDownloads = drawerComponentMenu.compDownloads,
|
||||
componentDownloadRevision = drawerComponentMenu.compDownloadRevision,
|
||||
onComponentCatalogRefresh = { drawerComponentMenu.refreshComponentCatalog() },
|
||||
onComponentDownload = drawerComponentMenu::downloadComponent,
|
||||
gameProfiles = drawerGameProfileRefresh.data?.profiles.orEmpty(),
|
||||
selectedGameProfileId = drawerGameProfileId,
|
||||
selectedGameProfile = drawerGameProfileRefresh.data
|
||||
?.takeIf { it.selectedAppId == drawerGameProfileId }?.profile,
|
||||
gameProfilesLoaded = drawerGameProfileRefresh.loaded,
|
||||
gameProfileRefreshFailed = drawerGameProfileRefresh.failed,
|
||||
onSelectedGameProfile = ::selectDrawerGameProfile,
|
||||
onGameProfilesPageOpened = {
|
||||
drawerGameProfileFollowState.resumeFollowingSteam()
|
||||
drawerGameInventoryRevision++
|
||||
},
|
||||
onSelectedGameProfileRefresh = ::refreshDrawerGameProfile,
|
||||
gameFexPresetSaving = drawerGameFexPresetSaving,
|
||||
onGameFexPreset = ::setDrawerGameFexPreset,
|
||||
gameNoSteamSaving = drawerGameNoSteamSaving,
|
||||
onGameNoSteam = ::setDrawerGameNoSteam,
|
||||
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
|
||||
onGameComponent = ::setDrawerGameComponent,
|
||||
))
|
||||
// The panel takes the pad, as the PC keyboard does.
|
||||
LaunchedEffect(achievementsOpen) {
|
||||
if (achievementsOpen) {
|
||||
padBridge?.releaseAll()
|
||||
withFrameNanos { }
|
||||
sessionOverlay.requestFocus()
|
||||
}
|
||||
}
|
||||
achievementsInFront?.takeIf { achievementsOpen }?.let { inFront ->
|
||||
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
|
||||
}
|
||||
com.droiddeck.launcher.ui.AchievementNotificationOverlay()
|
||||
if (SessionState.suspended) SessionPausedOverlay(
|
||||
title = pausedTitle(),
|
||||
onResume = { SessionService.resume(this@SessionActivity) },
|
||||
@@ -708,6 +858,28 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
padBridge?.triggerQam()
|
||||
}
|
||||
|
||||
private fun closeAchievements() { achievementsOpen = false }
|
||||
|
||||
/** A live game window only; Steam's overlay may be in front of that window. */
|
||||
private fun resolveAchievementsInFront(): AchievementsInFront? {
|
||||
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) return null
|
||||
val snapshot = AgentGuest.focus(this) ?: return null
|
||||
val games = LibraryCache.load(this)
|
||||
val byKey = games.mapNotNull { g -> g.profileKey.toLongOrNull()?.let { it to g } }.toMap()
|
||||
val windows = snapshot.optJSONArray("focusableApps")?.let { list -> List(list.length()) { list.optLong(it) } }.orEmpty()
|
||||
val focusedId = snapshot.optLong("focusedApp").takeUnless { snapshot.isNull("focusedApp") }
|
||||
val appId = com.droiddeck.launcher.session.SessionAchievementGame.activeAppId(focusedId, windows, byKey.keys) ?: return null
|
||||
val focused = byKey[appId] ?: return null
|
||||
val store = Store.byId(focused.source) ?: return null
|
||||
val id = focused.storeId ?: return null
|
||||
val source = StoreAchievements.forSource(store.id) ?: return null
|
||||
val sidecar = StoreGameSidecar.find(this, store, id) ?: return null
|
||||
val game = StoreAchievements.Game.of(sidecar)
|
||||
val list = source.cached(this, game) ?: (source.fetch(this, game) as? StoreAchievements.Result.Found)?.list ?: return null
|
||||
if (list.isEmpty()) return null
|
||||
return AchievementsInFront(game, sidecar.title, list.count { it.unlocked } to list.size)
|
||||
}
|
||||
|
||||
private fun togglePcKeyboard() {
|
||||
drawerOpen = false
|
||||
keyboard?.hide()
|
||||
@@ -763,6 +935,107 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "drawer-components").start()
|
||||
}
|
||||
|
||||
private fun selectDrawerGameProfile(appId: Long?) {
|
||||
drawerGameProfileFollowState.selectManually()
|
||||
drawerGameProfileId = appId
|
||||
drawerGameProfileRefresh = drawerGameProfileRefresh.retrying()
|
||||
readDrawerTextureFiltering(appId)
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
|
||||
private fun readDrawerTextureFiltering(appId: Long?) {
|
||||
val choice = appId?.let { SessionPrefs.gameTextureFiltering(this, it.toString()) }
|
||||
drawerTextureAnisotropy = choice?.anisotropy
|
||||
drawerTextureLodBias = choice?.lodBias
|
||||
}
|
||||
|
||||
private fun refreshDrawerGameProfile() {
|
||||
val refresh = DrawerGameProfileRefresh(
|
||||
drawerGameProfileFollowState.followsSteam,
|
||||
drawerGameProfileId,
|
||||
drawerGameInventoryRevision,
|
||||
)
|
||||
if (!drawerGameProfileRefreshes.submit(refresh)) return
|
||||
Thread({
|
||||
var request = drawerGameProfileRefreshes.takeLatest()
|
||||
while (request != null) {
|
||||
val current = request
|
||||
val result = resolveDrawerGameProfileRefresh(current.value)
|
||||
uiHandler.post {
|
||||
if (!drawerGameProfileRefreshes.isLatest(current)) return@post
|
||||
applyDrawerGameProfileRefresh(current.value, result)
|
||||
}
|
||||
request = drawerGameProfileRefreshes.takeLatest()
|
||||
}
|
||||
}, "drawer-game-profile").start()
|
||||
}
|
||||
|
||||
private fun resolveDrawerGameProfileRefresh(
|
||||
refresh: DrawerGameProfileRefresh,
|
||||
): DrawerGameProfileResult {
|
||||
return try {
|
||||
val games = drawerGameInventory.get(refresh.inventoryRevision) { Library.launchableGames(this) }
|
||||
val options = games.mapNotNull { game ->
|
||||
game.profileKey.toLongOrNull()?.let { it to game.name }
|
||||
}.sortedBy { it.second.lowercase() }
|
||||
val installedAppIds = options.mapTo(mutableSetOf()) { it.first }
|
||||
val selectedAppId = if (refresh.followsSteam) {
|
||||
val activeAppId = AgentGuest.activeSteamAppId(this, installedAppIds)
|
||||
val selectedSteamAppId = runCatching { AgentGuest.selectedSteamAppId(this) }
|
||||
AgentGuest.selectedOrActiveInstalledSteamAppId(selectedSteamAppId, activeAppId, installedAppIds)
|
||||
?: refresh.requestedAppId
|
||||
} else {
|
||||
refresh.requestedAppId
|
||||
}
|
||||
val profile = selectedAppId?.let { resolveDrawerGameProfile(it, games) }
|
||||
DrawerGameProfileResult(options, selectedAppId, profile)
|
||||
} catch (error: Exception) {
|
||||
DrawerGameProfileResult(emptyList(), refresh.requestedAppId, null, error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDrawerGameProfileRefresh(
|
||||
refresh: DrawerGameProfileRefresh,
|
||||
result: DrawerGameProfileResult,
|
||||
) {
|
||||
if (result.error != null) {
|
||||
if (!drawerGameProfileRefresh.failed) {
|
||||
Log.w(TAG, "Could not refresh the selected game profile", result.error)
|
||||
}
|
||||
drawerGameProfileRefresh = drawerGameProfileRefresh.failed()
|
||||
return
|
||||
}
|
||||
drawerGameProfileRefresh = drawerGameProfileRefresh.succeeded(result)
|
||||
if (refresh.followsSteam) {
|
||||
if (drawerGameProfileFollowState.followsSteam) {
|
||||
drawerGameProfileId = result.selectedAppId
|
||||
readDrawerTextureFiltering(result.selectedAppId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDrawerGameProfile(
|
||||
selectedAppId: Long,
|
||||
games: List<Library.SteamGame>,
|
||||
): SelectedGameProfile? {
|
||||
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
|
||||
val snapshot = drawerComponents ?: ComponentsManager.snapshot(this)
|
||||
val protons = snapshot.protons.map { it.proton }
|
||||
val gameChoice = ProtonDefault.gameChoice(this, game.profileKey)
|
||||
return SelectedGameProfile.resolve(
|
||||
selectedAppId = selectedAppId,
|
||||
games = games,
|
||||
generalFexPreset = SessionPrefs.fexPreset(this),
|
||||
environment = GameEnvironmentStore.read(this),
|
||||
snapshot = snapshot,
|
||||
generalProtonId = ProtonDefault.selectedId(this, protons),
|
||||
gameProtonChoice = gameChoice,
|
||||
gameProtonId = ProtonDefault.gameSelectedId(this, game.profileKey, protons),
|
||||
gameComponents = ComponentsManager.gameComponents(this, game.profileKey),
|
||||
gameWindowsComponents = WinComponents.picks(this, game.profileKey),
|
||||
)
|
||||
}
|
||||
|
||||
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
@@ -784,6 +1057,63 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "drawer-components-swap").start()
|
||||
}
|
||||
|
||||
private fun setDrawerGameComponent(appId: Long, comp: String, file: String?) {
|
||||
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
ComponentsManager.setGameComponent(this, appId.toString(), comp, file)
|
||||
getString(R.string.comp_game_saved_next, name)
|
||||
}.getOrElse { e ->
|
||||
getString(R.string.session_profile_save_failed, e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
uiHandler.post {
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshDrawerComponents()
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
}, "drawer-game-component").start()
|
||||
}
|
||||
|
||||
private fun setDrawerGameFexPreset(appId: Long, preset: String?) {
|
||||
if (drawerGameFexPresetSaving) return
|
||||
drawerGameFexPresetSaving = true
|
||||
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
|
||||
Thread({
|
||||
val message = try {
|
||||
GameEnvironmentStore.setGameFexPreset(this, appId.toString(), preset)
|
||||
getString(R.string.comp_game_saved_next, name)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Could not save the game FEX preset for $appId", error)
|
||||
getString(R.string.session_profile_save_failed, error.message ?: error.javaClass.simpleName)
|
||||
}
|
||||
uiHandler.post {
|
||||
drawerGameFexPresetSaving = false
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
}, "drawer-game-fex-preset").start()
|
||||
}
|
||||
|
||||
private fun setDrawerGameNoSteam(appId: Long, on: Boolean) {
|
||||
if (drawerGameNoSteamSaving) return
|
||||
drawerGameNoSteamSaving = true
|
||||
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
|
||||
Thread({
|
||||
val message = try {
|
||||
GameEnvironmentStore.setGameNoSteam(this, appId.toString(), on)
|
||||
getString(R.string.comp_game_saved_next, name)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Could not save Start without Steam for $appId", error)
|
||||
getString(R.string.session_profile_save_failed, error.message ?: error.javaClass.simpleName)
|
||||
}
|
||||
uiHandler.post {
|
||||
drawerGameNoSteamSaving = false
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
}, "drawer-game-no-steam").start()
|
||||
}
|
||||
|
||||
/** The session this screen is bringing up: the live one when re-attached, else the intent's. */
|
||||
private fun loadingMode(): String =
|
||||
if (SessionState.running) SessionState.mode
|
||||
@@ -836,6 +1166,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
effects = SessionPrefs.screenEffects(this)
|
||||
applyCardboard(SessionPrefs.cardboard(this))
|
||||
// A switch left from before this activity (or this resume): SteamVR was running then, so the next
|
||||
// "not running" hands the picture back.
|
||||
if (SessionPrefs.cardboardSourceBeforeSteamVr(this) >= 0 && !steamVrSwitchedPicture) {
|
||||
steamVrSwitchedPicture = true
|
||||
steamVrSeenRunning = true
|
||||
}
|
||||
textureAnisotropy = SessionPrefs.textureAnisotropy(this)
|
||||
textureLodBias = SessionPrefs.textureLodBias(this)
|
||||
val nextTouchMode = SessionPrefs.touchMode(this)
|
||||
@@ -1041,6 +1378,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSetUpscaler(SessionPrefs.upscaler(this))
|
||||
WaylandCompositor.nativeSetUpscaleSharpness(SessionPrefs.upscaleSharpness(this))
|
||||
SessionPrefs.screenEffects(this).push(SessionPrefs.upscaler(this))
|
||||
panelSizeMm().let { (w, h) -> SessionPrefs.cardboard(this).push(w, h) }
|
||||
WaylandCompositor.nativeSetCardboardPoseFile(File(filesDir, ".wayland-rt/droiddeck-headpose").path)
|
||||
// The session's folder, claimed here because the compositor starts before the service and
|
||||
// opens its log once. The compositor reads the path from its environment; setting it after
|
||||
// it has started changes nothing, which is why the service copies the file in at teardown.
|
||||
@@ -1146,12 +1485,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/**
|
||||
* Follow screen on a large panel (a Fold's inner screen) may turn with the device: a window
|
||||
* locked to landscape on a squarer panel is letterboxed by the system itself, which no output
|
||||
* size can undo. A phone-sized panel (the cover screen) stays landscape as before.
|
||||
* size can undo. Every other case keeps the landscape orientation picked in Setup.
|
||||
*/
|
||||
private fun applyFollowOrientation(config: android.content.res.Configuration) {
|
||||
if (!SessionState.followScreen) return
|
||||
val wanted = if (config.smallestScreenWidthDp >= 600) android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER
|
||||
else android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
val wanted = if (SessionState.followScreen && config.smallestScreenWidthDp >= 600) {
|
||||
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER
|
||||
} else {
|
||||
SessionPrefs.orientation(this)
|
||||
}
|
||||
if (requestedOrientation != wanted) requestedOrientation = wanted
|
||||
}
|
||||
|
||||
@@ -1197,13 +1538,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
|
||||
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
|
||||
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
|
||||
* Never above the user's peak refresh rate, which Android holds the panel to whatever mode is
|
||||
* active; it is the rate gamescope gives Steam, and the QAM's Frame Limit divides it.
|
||||
*/
|
||||
private fun refreshHz(): Float {
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
|
||||
val wanted = window.attributes.preferredDisplayModeId
|
||||
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
?: display?.refreshRate ?: 60f
|
||||
return if (hz > 1f) hz else 60f
|
||||
val capped = minOf(hz, PerfMode.peakRefreshCap(this))
|
||||
return if (capped > 1f) capped else 60f
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1438,7 +1782,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (pcKeyboardOpen && fromController) return super.dispatchKeyEvent(event)
|
||||
if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event)
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
|
||||
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
|
||||
// activity, which opens the drawer.
|
||||
@@ -1491,7 +1835,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
||||
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if (padBridge?.onMotionEvent(event) == true) return true
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
@@ -1929,7 +2273,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun updatePointerCapture() {
|
||||
if (!::surfaceView.isInitialized) return
|
||||
val view = window.decorView
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !pipUi && mouseAttached()
|
||||
if (want == view.hasPointerCapture()) return
|
||||
if (want) view.requestPointerCapture() else view.releasePointerCapture()
|
||||
}
|
||||
@@ -2101,6 +2445,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (show) {
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
pendingBackAction?.let(uiHandler::removeCallbacks)
|
||||
pendingBackAction = null
|
||||
releaseDrawerDirection()
|
||||
@@ -2198,7 +2543,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
applyFollowOrientation(resources.configuration)
|
||||
if (runtimeRemovalBlocked) return
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
refreshHomeApp()
|
||||
@@ -2208,6 +2553,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
(getSystemService(INPUT_SERVICE) as? InputManager)
|
||||
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
|
||||
if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers()
|
||||
onScreenControls?.reload()
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
updateOnScreenControls()
|
||||
@@ -2218,6 +2564,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
readPrefs()
|
||||
if (::pip.isInitialized) pip.resumed()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
SessionPrefs.sessionPrefs(this).registerOnSharedPreferenceChangeListener(cardboardPrefListener)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -2227,6 +2574,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// drawer's dim layer can otherwise remain over Steam when this activity returns.
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
|
||||
releaseDrawerDirection()
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
@@ -2235,9 +2583,103 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
updatePointerCapture()
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
SessionPrefs.sessionPrefs(this).unregisterOnSharedPreferenceChangeListener(cardboardPrefListener)
|
||||
updateHeadTracker()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
/** SteamVR for Cardboard mode: install, start or stop it in the guest. Starting switches the
|
||||
* picture to side-by-side, which is what SteamVR draws. */
|
||||
private fun steamVrAction(action: String) {
|
||||
if (action == "start") {
|
||||
switchPictureForSteamVr()
|
||||
steamVrSeenRunning = false
|
||||
}
|
||||
if (action == "stop") restoreSteamVrPicture()
|
||||
if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING
|
||||
Thread({
|
||||
val ok = SteamVr.run(this, action)
|
||||
val now = SteamVr.status(this)
|
||||
uiHandler.post {
|
||||
// A start that failed leaves nothing side-by-side to show: the picture goes back.
|
||||
if (!ok && action == "start" && now != SteamVr.Status.RUNNING) restoreSteamVrPicture()
|
||||
onSteamVrStatus(now)
|
||||
if (!ok) Toast.makeText(this, getString(R.string.cardboard_steamvr_failed), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "steamvr-$action").start()
|
||||
}
|
||||
|
||||
/** SteamVR draws a side-by-side pair: the picture switches to that, remembering what it was. */
|
||||
private fun switchPictureForSteamVr() {
|
||||
if (cardboard.source != Cardboard.SOURCE_SBS) {
|
||||
SessionPrefs.setCardboardSourceBeforeSteamVr(this, cardboard.source)
|
||||
val c = cardboard.copy(source = Cardboard.SOURCE_SBS)
|
||||
SessionPrefs.setCardboard(this, c)
|
||||
applyCardboard(c)
|
||||
}
|
||||
steamVrSwitchedPicture = SessionPrefs.cardboardSourceBeforeSteamVr(this) >= 0
|
||||
}
|
||||
|
||||
private fun onSteamVrStatus(now: SteamVr.Status) {
|
||||
steamVrStatus = now
|
||||
// Started some other way than the drawer (a script, a VR game): the picture follows it all the same.
|
||||
if (now == SteamVr.Status.RUNNING && cardboard.enabled && !steamVrSwitchedPicture &&
|
||||
cardboard.source != Cardboard.SOURCE_SBS) {
|
||||
switchPictureForSteamVr()
|
||||
steamVrSeenRunning = true
|
||||
}
|
||||
if (!steamVrSwitchedPicture) return
|
||||
if (now == SteamVr.Status.RUNNING) steamVrSeenRunning = true
|
||||
else if (steamVrSeenRunning && now != SteamVr.Status.UNAVAILABLE) restoreSteamVrPicture()
|
||||
}
|
||||
|
||||
/** SteamVR stopped: the Cardboard picture goes back to what it was before Start. */
|
||||
private fun restoreSteamVrPicture() {
|
||||
val before = SessionPrefs.cardboardSourceBeforeSteamVr(this)
|
||||
SessionPrefs.setCardboardSourceBeforeSteamVr(this, -1)
|
||||
steamVrSwitchedPicture = false
|
||||
steamVrSeenRunning = false
|
||||
if (before < 0 || cardboard.source == before) return
|
||||
val c = cardboard.copy(source = before)
|
||||
SessionPrefs.setCardboard(this, c)
|
||||
applyCardboard(c)
|
||||
}
|
||||
|
||||
/** Cardboard mode: hand the settings to the compositor and run the head tracker while it needs one. */
|
||||
private fun applyCardboard(c: Cardboard) {
|
||||
val was = cardboard
|
||||
cardboard = c
|
||||
if (CompositorHost.isStarted) panelSizeMm().let { (w, h) -> c.push(w, h) }
|
||||
if (c.enabled && !was.enabled) headTracker?.recenter()
|
||||
updateHeadTracker()
|
||||
}
|
||||
|
||||
private fun updateHeadTracker() {
|
||||
// Side-by-side sources track too: the compositor leaves their picture alone, but whoever renders
|
||||
// it (the SteamVR driver, tools/vr) reads the pose from the shared file.
|
||||
val want = resumed && cardboard.enabled && cardboard.headTracking
|
||||
if (want) {
|
||||
val t = headTracker ?: HeadTracker(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
}.also { headTracker = it }
|
||||
t.start()
|
||||
} else {
|
||||
headTracker?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** The panel's physical size in millimetres, long side first, for lining the eyes up with the lenses. */
|
||||
private fun panelSizeMm(): Pair<Float, Float> {
|
||||
val dm = resources.displayMetrics
|
||||
val landscape = dm.widthPixels >= dm.heightPixels
|
||||
val longPx = maxOf(dm.widthPixels, dm.heightPixels).toFloat()
|
||||
val shortPx = minOf(dm.widthPixels, dm.heightPixels).toFloat()
|
||||
val longDpi = if (landscape) dm.xdpi else dm.ydpi
|
||||
val shortDpi = if (landscape) dm.ydpi else dm.xdpi
|
||||
return (if (longDpi > 0f) longPx / longDpi * 25.4f else 0f) to (if (shortDpi > 0f) shortPx / shortDpi * 25.4f else 0f)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
|
||||
@@ -2401,3 +2843,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** [progress]: (unlocked, total) from the cached list. */
|
||||
private class AchievementsInFront(val game: StoreAchievements.Game, val title: String, val progress: Pair<Int, Int>?)
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import android.os.FileObserver
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
@@ -16,6 +21,9 @@ import java.util.UUID
|
||||
* whatever runs does so inside the live session with its own environment.
|
||||
*/
|
||||
object AgentGuest {
|
||||
private val main by lazy { Handler(Looper.getMainLooper()) }
|
||||
var focusRevision by mutableIntStateOf(0)
|
||||
private set
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
private val lock = Any()
|
||||
@@ -70,6 +78,7 @@ object AgentGuest {
|
||||
if (key == lastFocusKey) return
|
||||
lastFocusKey = key
|
||||
}
|
||||
main.post { focusRevision++ }
|
||||
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
@@ -85,6 +94,63 @@ object AgentGuest {
|
||||
fun focus(context: Context): JSONObject? =
|
||||
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
|
||||
|
||||
/** The Steam library game whose tile currently has controller or keyboard focus. */
|
||||
fun selectedSteamAppId(context: Context): Long? {
|
||||
val result = call(context, JSONObject().put("kind", "steam-selection"), 6_000)
|
||||
if (!result.optBoolean("ok") || result.isNull("appId")) return null
|
||||
return result.optLong("appId").takeIf { it in 1..4_294_967_295L }
|
||||
}
|
||||
|
||||
/** The installed game Gamescope is presenting, including while Steam's overlay has focus. */
|
||||
fun activeSteamAppId(context: Context, installedAppIds: Set<Long>): Long? {
|
||||
val snapshot = focus(context) ?: return null
|
||||
val focusedAppId = snapshot.optLong("focusedApp").takeIf { snapshot.has("focusedApp") }
|
||||
val focusableAppIds = snapshot.optJSONArray("focusableApps")?.let { apps ->
|
||||
buildList {
|
||||
for (index in 0 until apps.length()) {
|
||||
apps.optLong(index).takeIf { it > 0 }?.let(::add)
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
val baselayerAppIds = snapshot.optJSONArray("baselayerAppIds")?.let { apps ->
|
||||
buildList {
|
||||
for (index in 0 until apps.length()) {
|
||||
apps.optLong(index).takeIf { it > 0 }?.let(::add)
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
return activeInstalledSteamAppId(focusedAppId, focusableAppIds, installedAppIds, baselayerAppIds)
|
||||
}
|
||||
|
||||
internal fun focusedInstalledSteamAppId(focusedAppId: Long?, installedAppIds: Set<Long>): Long? =
|
||||
focusedAppId?.takeIf { it in installedAppIds }
|
||||
|
||||
internal fun activeInstalledSteamAppId(
|
||||
focusedAppId: Long?,
|
||||
focusableAppIds: List<Long>,
|
||||
installedAppIds: Set<Long>,
|
||||
baselayerAppIds: List<Long> = emptyList(),
|
||||
): Long? = focusedInstalledSteamAppId(focusedAppId, installedAppIds)
|
||||
?: focusableAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
|
||||
?: baselayerAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
|
||||
|
||||
internal fun preferredInstalledSteamAppId(
|
||||
selectedAppId: Long?,
|
||||
activeAppId: Long?,
|
||||
installedAppIds: Set<Long>,
|
||||
): Long? = selectedAppId?.takeIf { it in installedAppIds }
|
||||
?: activeAppId?.takeIf { it in installedAppIds }
|
||||
|
||||
internal fun selectedOrActiveInstalledSteamAppId(
|
||||
selectedAppId: Result<Long?>,
|
||||
activeAppId: Long?,
|
||||
installedAppIds: Set<Long>,
|
||||
): Long? {
|
||||
preferredInstalledSteamAppId(selectedAppId.getOrNull(), activeAppId, installedAppIds)?.let { return it }
|
||||
selectedAppId.exceptionOrNull()?.let { throw it }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.gpu.SocNames
|
||||
|
||||
/** Device defaults only: affinity does not reserve cores or lock their clocks. */
|
||||
object CpuAllocationDefaults {
|
||||
data class Allocation(val client: String, val game: String)
|
||||
|
||||
val device: Allocation? by lazy {
|
||||
forDevice(SocNames.model(), GpuInfo.systemProperty("ro.board.platform"),
|
||||
CpuCores.all.associateWith(CpuCores::maxGhz))
|
||||
}
|
||||
|
||||
/** Select by topology, including faster SM8550 variants; never guess core indices. */
|
||||
fun forDevice(model: String, platform: String, frequencies: Map<Int, Double?>): Allocation? {
|
||||
val chip = model.trim().uppercase().substringBefore('-')
|
||||
val gen2 = chip in setOf("SM8550", "QCS8550") ||
|
||||
(chip.isEmpty() && platform.trim().equals("kalama", ignoreCase = true))
|
||||
if (!gen2 || frequencies.size != 8 || frequencies.keys.any { it < 0 } ||
|
||||
frequencies.values.any { it == null || !it.isFinite() || it <= 0.0 }) return null
|
||||
val clusters = frequencies.entries.groupBy { it.value }.toSortedMap(compareBy { it ?: 0.0 }).values.toList()
|
||||
if (clusters.map { it.size } != listOf(3, 4, 1)) return null
|
||||
return Allocation(
|
||||
client = CpuCores.format(clusters[2].map { it.key }.toSet()),
|
||||
game = CpuCores.format(clusters[1].map { it.key }.toSet()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ object CpuCores {
|
||||
return format(if (cores.isEmpty()) all.toSet() else cores)
|
||||
}
|
||||
|
||||
/** An all-core game must undo the affinity inherited from a pinned Steam client. */
|
||||
fun gameLaunchList(list: String, clientPinned: Boolean): String =
|
||||
restrictionOrEmpty(list).ifEmpty { if (clientPinned) listOrAll("") else "" }
|
||||
|
||||
/**
|
||||
* The list only when it is a real restriction, else "". Empty, absent, or naming every core
|
||||
* all mean "no preference": pinning a process to all cores is what the scheduler does
|
||||
|
||||
@@ -147,7 +147,7 @@ object DeviceReport {
|
||||
h("Settings in effect")
|
||||
k("Client core override", SessionPrefs.clientCpusOverride(context))
|
||||
k(" client cores", CpuCores.listOrAll(SessionPrefs.clientCpus(context)))
|
||||
k(" game cores", CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(context)).ifEmpty { "every core (nothing sent)" })
|
||||
k(" game cores", CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(context)).ifEmpty { "every core" })
|
||||
k("Turnip sysmem", SessionPrefs.tuSysmem(context))
|
||||
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
|
||||
@@ -30,4 +30,11 @@ object FexPreset {
|
||||
|
||||
/** Game-launch variables in KEY=VALUE form. */
|
||||
fun env(id: String): List<String> = byId(id).env
|
||||
|
||||
val environmentKeys: Set<String> by lazy {
|
||||
all.flatMap { it.env }.mapTo(linkedSetOf()) { it.substringBefore('=') }
|
||||
}
|
||||
|
||||
fun environment(id: String): Map<String, String> =
|
||||
env(id).associate { it.substringBefore('=') to it.substringAfter('=') }
|
||||
}
|
||||
@@ -44,6 +44,53 @@ object GameEnvironment {
|
||||
|
||||
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
|
||||
|
||||
/**
|
||||
* A game's Start without Steam: droiddeck-game-env launches it without Proton's steam.exe (umu's
|
||||
* launch where the Proton has it, else runinprefix), for games that close right away.
|
||||
*/
|
||||
const val NO_STEAM = "DROIDDECK_NO_STEAM"
|
||||
|
||||
fun gameNoSteam(config: Config, scope: String): Boolean =
|
||||
scope.isNotEmpty() && config.games[scope]?.get(NO_STEAM)?.lowercase() in setOf("1", "on", "true", "yes")
|
||||
|
||||
fun withGameNoSteam(config: Config, scope: String, on: Boolean): Config {
|
||||
require(scope.isNotEmpty() && validScope(scope))
|
||||
return withScopeValue(config, scope, NO_STEAM, if (on) "1" else null)
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
|
||||
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
|
||||
fun hasOverride(config: Config, scope: String, name: String): Boolean =
|
||||
scope.isNotEmpty() && config.entries(scope).containsKey(name)
|
||||
|
||||
fun withScopeValue(config: Config, scope: String, name: String, value: String?): Config {
|
||||
require(scope.isEmpty() || validScope(scope))
|
||||
val entries = config.entries(scope).toMutableMap()
|
||||
if (value == null) entries.remove(name) else entries[name] = value
|
||||
return config.withEntries(scope, entries)
|
||||
}
|
||||
|
||||
fun gameFexPreset(config: Config, scope: String): String? {
|
||||
require(scope.isNotEmpty() && validScope(scope))
|
||||
val entries = config.games[scope].orEmpty()
|
||||
if (FexPreset.environmentKeys.none(entries::containsKey)) return null
|
||||
return FexPreset.all.firstOrNull { preset ->
|
||||
val expected = FexPreset.environment(preset.id)
|
||||
FexPreset.environmentKeys.all { key -> entries.containsKey(key) && entries[key] == expected[key] }
|
||||
}?.id ?: CUSTOM_FEX_PRESET
|
||||
}
|
||||
|
||||
fun withGameFexPreset(config: Config, scope: String, preset: String?): Config {
|
||||
require(scope.isNotEmpty() && validScope(scope))
|
||||
require(preset == null || FexPreset.all.any { it.id == preset })
|
||||
val entries = config.entries(scope).filterKeys { it !in FexPreset.environmentKeys }.toMutableMap()
|
||||
if (preset != null) {
|
||||
val values = FexPreset.environment(preset)
|
||||
FexPreset.environmentKeys.forEach { key -> entries[key] = values[key] }
|
||||
}
|
||||
return config.withEntries(scope, entries)
|
||||
}
|
||||
|
||||
const val CUSTOM_FEX_PRESET = "__custom__"
|
||||
}
|
||||
@@ -31,6 +31,8 @@ object Library {
|
||||
) {
|
||||
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
/** Key used by per-game environment and Windows-component profiles. */
|
||||
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
|
||||
}
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
|
||||
const val ADDED = "added"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Cardboard mode: the compositor draws the session as two lens-distorted eye views side by side
|
||||
* (waylandcomp/src/cardboard.c), for a phone or handheld dropped into a Google Cardboard-style
|
||||
* viewer. Units are the menu's own:
|
||||
*
|
||||
* - [ipdMm] distance between the eyes, millimetres
|
||||
* - [screenWidthDm] the virtual screen's width, decimetres (40 = 4 m)
|
||||
* - [screenDistanceDm] how far away it hangs, decimetres (30 = 3 m)
|
||||
* - [viewer] a [Viewer] id: the lens it is drawn for
|
||||
*
|
||||
* [source] SCREEN shows the frame as a flat virtual screen, world-locked by the rotation sensor
|
||||
* when [headTracking] is on (3DoF), with real stereo depth from the IPD. SBS takes a frame that is
|
||||
* already a left/right pair (SteamVR, SBS 3D games and video) and only adds the lens.
|
||||
*/
|
||||
data class Cardboard(
|
||||
val enabled: Boolean = false,
|
||||
val source: Int = SOURCE_SCREEN,
|
||||
val viewer: String = Viewer.CARDBOARD_V2.id,
|
||||
val ipdMm: Int = 64,
|
||||
val screenWidthDm: Int = 40,
|
||||
val screenDistanceDm: Int = 30,
|
||||
val headTracking: Boolean = true,
|
||||
) {
|
||||
val lens: Viewer get() = Viewer.byId(viewer)
|
||||
|
||||
/**
|
||||
* Hands the settings to the compositor. [panelWidthMm] x [panelHeightMm] is the physical size of
|
||||
* the panel the eyes are drawn on (0 = unknown). The width decides how far each lens centre sits
|
||||
* from its viewport's centre, so the two pictures line up with the viewer's lenses; the height,
|
||||
* seen through the lens from its focal distance, is the field of view the distortion works in.
|
||||
*/
|
||||
fun push(panelWidthMm: Float, panelHeightMm: Float) {
|
||||
val l = lens
|
||||
WaylandCompositor.nativeSetCardboard(enabled, source, ipdMm / 1000f, screenDistanceDm / 10f,
|
||||
screenWidthDm / 10f, l.k1, l.k2, fovDeg(l, panelHeightMm), lensInset(panelWidthMm, l.lensSeparationMm), l.chroma)
|
||||
}
|
||||
|
||||
fun encode(): JSONObject = JSONObject()
|
||||
.put("enabled", enabled).put("source", source).put("viewer", viewer).put("ipdMm", ipdMm)
|
||||
.put("screenWidthDm", screenWidthDm).put("screenDistanceDm", screenDistanceDm).put("headTracking", headTracking)
|
||||
|
||||
/**
|
||||
* A lens the eyes can be drawn for: its distortion (Cardboard viewer profile coefficients, in
|
||||
* tangent units at the panel), how far it sits from the panel, and how far apart the two are.
|
||||
* [fallbackFovDeg] is the eye's field of view when the panel's size is unknown, or with no lens.
|
||||
*/
|
||||
enum class Viewer(val id: String, val k1: Float, val k2: Float, val screenToLensMm: Float,
|
||||
val lensSeparationMm: Float, val chroma: Float, val fallbackFovDeg: Float) {
|
||||
/** Google Cardboard (2015): the common plastic and cardboard viewers. */
|
||||
CARDBOARD_V2("cardboard2", 0.34f, 0.55f, 39.3f, 63.9f, 0.006f, 90f),
|
||||
/** The original 2014 Cardboard: narrower, gentler lenses. */
|
||||
CARDBOARD_V1("cardboard1", 0.441f, 0.156f, 42f, 60f, 0.004f, 80f),
|
||||
/** No lens: side-by-side 3D glasses, a 3D TV, or just looking at the stereo pair. */
|
||||
NONE("none", 0f, 0f, 0f, 0f, 0f, 60f);
|
||||
|
||||
companion object {
|
||||
fun byId(id: String): Viewer = entries.firstOrNull { it.id == id } ?: CARDBOARD_V2
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SOURCE_SCREEN = 0
|
||||
const val SOURCE_SBS = 1
|
||||
|
||||
val OFF = Cardboard()
|
||||
|
||||
fun decode(json: JSONObject): Cardboard = Cardboard(
|
||||
enabled = json.optBoolean("enabled", false),
|
||||
source = if (json.optInt("source", SOURCE_SCREEN) == SOURCE_SBS) SOURCE_SBS else SOURCE_SCREEN,
|
||||
viewer = Viewer.byId(json.optString("viewer", Viewer.CARDBOARD_V2.id)).id,
|
||||
ipdMm = json.optInt("ipdMm", 64).coerceIn(50, 80),
|
||||
screenWidthDm = json.optInt("screenWidthDm", 40).coerceIn(5, 200),
|
||||
screenDistanceDm = json.optInt("screenDistanceDm", 30).coerceIn(5, 200),
|
||||
headTracking = json.optBoolean("headTracking", true),
|
||||
)
|
||||
|
||||
/**
|
||||
* One eye's vertical field of view on the panel side of the lens: half the panel's height
|
||||
* seen from the lens's focal distance. The lens then spreads it wider (the distortion).
|
||||
*/
|
||||
fun fovDeg(viewer: Viewer, panelHeightMm: Float): Float {
|
||||
if (panelHeightMm <= 0f || viewer.screenToLensMm <= 0f) return viewer.fallbackFovDeg
|
||||
val half = Math.toDegrees(kotlin.math.atan((panelHeightMm / 2f / viewer.screenToLensMm).toDouble())).toFloat()
|
||||
return (2f * half).coerceIn(30f, 140f)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lens centre's inset towards the nose, as a fraction of one eye viewport's width. Each
|
||||
* eye gets half the panel, so its viewport centre is a quarter of the panel from the middle;
|
||||
* the lens centre is half the lens separation from it. 0 when either is unknown.
|
||||
*/
|
||||
fun lensInset(panelWidthMm: Float, lensSeparationMm: Float): Float {
|
||||
if (panelWidthMm <= 0f || lensSeparationMm <= 0f) return 0f
|
||||
val inset = (panelWidthMm / 4f - lensSeparationMm / 2f) / (panelWidthMm / 2f)
|
||||
return inset.coerceIn(-0.25f, 0.25f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head pose maths for Cardboard mode, kept free of Android types so it is unit-tested on the JVM.
|
||||
* Matrices are row-major 3x3 FloatArrays. "World" is Android's sensor world (x east, y north,
|
||||
* z up); "screen" axes are x right, y up, z out of the panel towards the viewer's eyes.
|
||||
*/
|
||||
object HeadPose {
|
||||
val IDENTITY = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
|
||||
|
||||
/** Unit quaternion (x, y, z, w) -> rotation matrix: what SensorManager.getRotationMatrixFromVector gives. */
|
||||
fun fromQuaternion(x: Float, y: Float, z: Float, w: Float): FloatArray {
|
||||
val n = sqrt(x * x + y * y + z * z + w * w).takeIf { it > 1e-6f } ?: 1f
|
||||
val qx = x / n; val qy = y / n; val qz = z / n; val qw = w / n
|
||||
return floatArrayOf(
|
||||
1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qz * qw), 2 * (qx * qz + qy * qw),
|
||||
2 * (qx * qy + qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz - qx * qw),
|
||||
2 * (qx * qz - qy * qw), 2 * (qy * qz + qx * qw), 1 - 2 * (qx * qx + qy * qy),
|
||||
)
|
||||
}
|
||||
|
||||
/** The rotation vector sensor's values -> (x, y, z, w), computing w when the sensor leaves it out. */
|
||||
fun quaternionFromRotationVector(v: FloatArray): FloatArray {
|
||||
val x = v[0]; val y = v[1]; val z = v[2]
|
||||
val w = if (v.size >= 4) v[3] else sqrt((1f - x * x - y * y - z * z).coerceAtLeast(0f))
|
||||
return floatArrayOf(x, y, z, w)
|
||||
}
|
||||
|
||||
/**
|
||||
* Device -> screen axes for a Display rotation (Surface.ROTATION_* as 0..3): the returned matrix
|
||||
* maps screen to world, given the device's own device -> world matrix. The same transform as
|
||||
* input/PadMotion's and SensorManager.remapCoordinateSystem's (ROTATION_90 = AXIS_Y, AXIS_MINUS_X:
|
||||
* the device's x is the screen's y, its y the screen's -x).
|
||||
*/
|
||||
fun toScreen(deviceToWorld: FloatArray, rotation: Int): FloatArray {
|
||||
// Columns of A: where each screen axis points in device coordinates.
|
||||
val a = when (rotation and 3) {
|
||||
1 -> floatArrayOf(0f, 1f, 0f, -1f, 0f, 0f, 0f, 0f, 1f) // screen x = -device y, y = device x
|
||||
2 -> floatArrayOf(-1f, 0f, 0f, 0f, -1f, 0f, 0f, 0f, 1f) // screen x = -device x, y = -device y
|
||||
3 -> floatArrayOf(0f, -1f, 0f, 1f, 0f, 0f, 0f, 0f, 1f) // screen x = device y, y = -device x
|
||||
else -> IDENTITY
|
||||
}
|
||||
return multiply(deviceToWorld, a)
|
||||
}
|
||||
|
||||
/** sin(60 degrees): a view further from level than this is "held flat" for recentring. */
|
||||
private const val FLAT_SIN = 0.866f
|
||||
|
||||
/**
|
||||
* What a recentre locks to. The pose the compositor gets is basis^T * screenToWorld * tilt:
|
||||
* - [basis]: a level frame (columns right, up, back in world) facing the recentred heading. Every
|
||||
* rotation is measured in it, so turning about the real vertical always pans the view;
|
||||
* - [tilt]: the device's own tilt at the recentre, taken out so that pose starts straight ahead.
|
||||
* Identity when the device was held upright: the horizon then stays the true one.
|
||||
*/
|
||||
class Reference(val basis: FloatArray, val tilt: FloatArray)
|
||||
|
||||
/**
|
||||
* Recentre on the current orientation. Held upright (the screen within 60 degrees of vertical, as
|
||||
* in a viewer), only the heading resets: forward is where the screen faces. Held flat (lying back,
|
||||
* or the device on a table or in the hands like a tray), forward is where the top of the screen
|
||||
* points and the tilt resets too; turning at the hips still pans, tipping the device still tilts.
|
||||
*/
|
||||
fun recenter(screenToWorld: FloatArray): Reference {
|
||||
val fz = -screenToWorld[8]
|
||||
val flat = kotlin.math.abs(fz) > FLAT_SIN
|
||||
// Upright: the screen's -z. Flat: the screen's +y (its top edge), which is level then.
|
||||
var fx = if (flat) screenToWorld[1] else -screenToWorld[2]
|
||||
var fy = if (flat) screenToWorld[4] else -screenToWorld[5]
|
||||
val len = sqrt(fx * fx + fy * fy).takeIf { it > 1e-4f } ?: run { fx = 0f; fy = 1f; 1f }
|
||||
fx /= len; fy /= len
|
||||
// right = forward x up(0,0,1) = (fy, -fx, 0); up = (0,0,1); back = -forward.
|
||||
val basis = floatArrayOf(
|
||||
fy, 0f, -fx,
|
||||
-fx, 0f, -fy,
|
||||
0f, 1f, 0f,
|
||||
)
|
||||
val tilt = if (flat) multiply(transpose(screenToWorld), basis) else IDENTITY
|
||||
return Reference(basis, tilt)
|
||||
}
|
||||
|
||||
/** head -> recentred world: what the compositor takes. */
|
||||
fun relative(ref: Reference, screenToWorld: FloatArray): FloatArray =
|
||||
multiply(multiply(transpose(ref.basis), screenToWorld), ref.tilt)
|
||||
|
||||
fun multiply(a: FloatArray, b: FloatArray): FloatArray = FloatArray(9) { i ->
|
||||
val r = i / 3; val c = i % 3
|
||||
a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c]
|
||||
}
|
||||
|
||||
fun transpose(m: FloatArray): FloatArray = floatArrayOf(m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8])
|
||||
|
||||
/** m * v for a 3-vector. */
|
||||
fun apply(m: FloatArray, v: FloatArray): FloatArray = floatArrayOf(
|
||||
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
|
||||
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
|
||||
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
/**
|
||||
* 3DoF head tracking for Cardboard mode: the rotation sensor -> the compositor's head pose
|
||||
* ([HeadPose] for the maths). Game rotation vector first (gyro + accelerometer: no compass jumps
|
||||
* indoors, and only the heading drifts, which a recentre fixes); the plain rotation vector where a
|
||||
* device has no game one. Events arrive on a thread of their own and go straight to native code,
|
||||
* so the session's UI thread never sits between the sensor and the screen.
|
||||
*
|
||||
* [rotation] is the Display rotation (Surface.ROTATION_*) the session is drawn in.
|
||||
*/
|
||||
class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEventListener {
|
||||
private val sensors = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
private val sensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
|
||||
?: sensors.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private var thread: HandlerThread? = null
|
||||
@Volatile private var reference: HeadPose.Reference? = null
|
||||
@Volatile private var recenterPending = true
|
||||
@Volatile private var latest: FloatArray? = null
|
||||
|
||||
/** Whether this device has a rotation sensor at all (no sensor = a fixed, head-locked screen). */
|
||||
val available: Boolean get() = sensor != null
|
||||
val sensorName: String get() = sensor?.let { "${it.name} (${if (it.type == Sensor.TYPE_GAME_ROTATION_VECTOR) "game rotation vector" else "rotation vector"})" } ?: "none"
|
||||
|
||||
fun start() {
|
||||
val s = sensor ?: run {
|
||||
WaylandCompositor.nativeLog("cardboard", "no rotation sensor: the virtual screen stays head-locked")
|
||||
WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false)
|
||||
return
|
||||
}
|
||||
if (thread != null) return
|
||||
recenterPending = true
|
||||
val t = HandlerThread("droiddeck-headtracker").also { it.start() }
|
||||
thread = t
|
||||
sensors.registerListener(this, s, SensorManager.SENSOR_DELAY_FASTEST, Handler(t.looper))
|
||||
WaylandCompositor.nativeLog("cardboard", "head tracking on: $sensorName")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val t = thread ?: return
|
||||
sensors.unregisterListener(this)
|
||||
t.quitSafely()
|
||||
thread = null
|
||||
WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false)
|
||||
}
|
||||
|
||||
/** Make where the viewer looks now straight ahead (see HeadPose.recenter). */
|
||||
fun recenter() {
|
||||
recenterPending = true
|
||||
latest?.let { apply(it) }
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val q = HeadPose.quaternionFromRotationVector(event.values)
|
||||
val m = HeadPose.toScreen(HeadPose.fromQuaternion(q[0], q[1], q[2], q[3]), rotation())
|
||||
latest = m
|
||||
apply(m)
|
||||
}
|
||||
|
||||
private fun apply(screenToWorld: FloatArray) {
|
||||
val ref = reference.takeUnless { recenterPending } ?: HeadPose.recenter(screenToWorld).also {
|
||||
reference = it
|
||||
recenterPending = false
|
||||
}
|
||||
WaylandCompositor.nativeSetCardboardPose(HeadPose.relative(ref, screenToWorld), true)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
|
||||
Log.d("DroidDeck", "head tracker accuracy $accuracy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.agent.AgentGuest
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Cardboard mode's SteamVR (docs/cardboard-vr.md): Valve's ARM64 SteamVR in the guest, rendering
|
||||
* through the DroidDeck driver as a side-by-side pair that the Cardboard pass puts the lens on. Driven
|
||||
* through the guest agent's fixed "vr" request (droiddeck-steamvr), so it needs no agent commands.
|
||||
* Every call blocks on the guest: run them off the main thread.
|
||||
*/
|
||||
object SteamVr {
|
||||
enum class Status {
|
||||
/** No session, no agent, or a runtime without the helper or the driver. */
|
||||
UNAVAILABLE,
|
||||
NOT_INSTALLED,
|
||||
/** Downloading or unpacking (571 MB). */
|
||||
INSTALLING,
|
||||
READY,
|
||||
RUNNING,
|
||||
}
|
||||
|
||||
fun status(context: Context): Status = runCatching {
|
||||
parse(AgentGuest.call(context, JSONObject().put("kind", "vr").put("action", "status"), STATUS_TIMEOUT_MS))
|
||||
}.getOrDefault(Status.UNAVAILABLE)
|
||||
|
||||
/** install, start or stop; true when the guest did it. */
|
||||
fun run(context: Context, action: String): Boolean = runCatching {
|
||||
require(action in setOf("install", "start", "stop"))
|
||||
succeeded(action, AgentGuest.call(context, JSONObject().put("kind", "vr").put("action", action), ACTION_TIMEOUT_MS))
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* The agent answers ok whenever it ran the request, even when droiddeck-steamvr then failed; the
|
||||
* helper's own exit code says whether start or stop worked. install runs detached and only reports
|
||||
* that it started (its progress shows in status).
|
||||
*/
|
||||
fun succeeded(action: String, answer: JSONObject): Boolean = when {
|
||||
!answer.optBoolean("ok", false) -> false
|
||||
action == "install" -> answer.optBoolean("started", false)
|
||||
else -> answer.optInt("exitCode", -1) == 0
|
||||
}
|
||||
|
||||
/** The agent's status answer -> what the menu shows. */
|
||||
fun parse(answer: JSONObject): Status = when {
|
||||
!answer.optBoolean("ok", false) -> Status.UNAVAILABLE
|
||||
answer.optInt("exitCode", 0) != 0 -> Status.UNAVAILABLE
|
||||
answer.optBoolean("installing") -> Status.INSTALLING
|
||||
!answer.optBoolean("driver") -> Status.UNAVAILABLE
|
||||
!answer.optBoolean("installed") -> Status.NOT_INSTALLED
|
||||
answer.optBoolean("running") -> Status.RUNNING
|
||||
else -> Status.READY
|
||||
}
|
||||
|
||||
private const val STATUS_TIMEOUT_MS = 10_000L
|
||||
private const val ACTION_TIMEOUT_MS = 40_000L
|
||||
}
|
||||
@@ -50,7 +50,8 @@ public final class TurnipDriver {
|
||||
/** Adreno 8xx (8 Elite, e.g. a Fold 8's A830). */
|
||||
private static final String DRIVER_A8XX = "turnip-sdk36";
|
||||
/** The two the apk carries; never removable, unpacked on demand. */
|
||||
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX));
|
||||
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(
|
||||
DRIVER_A7XX, DRIVER_A8XX, com.droiddeck.launcher.session.QuestExperimental.DRIVER));
|
||||
/** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */
|
||||
private static final String OVERRIDE_FILE = "Download/droiddeck-driver";
|
||||
/** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */
|
||||
|
||||
@@ -566,6 +566,16 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pad, as the Steam client now sees it, has a Quick Access button: a Deck's or a
|
||||
* Steam Controller's does, an Xbox or PlayStation pad's does not (libfakeinput drops the bit).
|
||||
* Unset is the Deck, libfakeinput's default.
|
||||
*/
|
||||
public static boolean padHasQam() {
|
||||
int type = padType;
|
||||
return type < 0 || type == PAD_TYPE_DECK || type == PAD_TYPE_STEAM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK],
|
||||
* [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new.
|
||||
|
||||
@@ -257,6 +257,14 @@ public final class PadBridge {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Publishes every currently connected controller so the client can enumerate all players. */
|
||||
public synchronized void syncConnectedControllers() {
|
||||
for (int deviceId : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(deviceId);
|
||||
if (isFromController(device)) publish(slotFor(device));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called (main thread) when a player presses a button, pushes a stick, trigger or d-pad past
|
||||
* halfway, or uses the on-screen controls - the session hides its mouse cursor then. Resting
|
||||
@@ -392,8 +400,8 @@ public final class PadBridge {
|
||||
boolean qamStarted = qamPressed && !systemQamPressed;
|
||||
systemGuidePressed = guidePressed;
|
||||
systemQamPressed = qamPressed;
|
||||
// A Deck controller's QAM button is held for as long as the touch one is.
|
||||
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
|
||||
// A Deck or Steam Controller's QAM button is held for as long as the touch one is.
|
||||
if (qamStarted && !nativeQam()) startQamChord();
|
||||
else publish();
|
||||
}
|
||||
|
||||
@@ -418,9 +426,18 @@ public final class PadBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
|
||||
/**
|
||||
* Whether the client's pad has its own QAM button to press. The Deck's sysfs being up is not
|
||||
* enough: the drawer can make the pad an Xbox or PlayStation one, which has none, and a press
|
||||
* of it never reaches the client.
|
||||
*/
|
||||
private static boolean nativeQam() {
|
||||
return SessionState.getDeckPad() && FakeInputWriter.padHasQam();
|
||||
}
|
||||
|
||||
/** Opens the client's Quick Access menu: a tap of the pad's button, or the Guide-then-A chord. */
|
||||
public synchronized void triggerQam() {
|
||||
if (!SessionState.getDeckPad()) {
|
||||
if (!nativeQam()) {
|
||||
startQamChord();
|
||||
return;
|
||||
}
|
||||
@@ -563,7 +580,7 @@ public final class PadBridge {
|
||||
return;
|
||||
}
|
||||
boolean guide = systemGuidePressed || qamChordActive;
|
||||
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed);
|
||||
if (!guide && !qam) {
|
||||
writer.writePad(state);
|
||||
return;
|
||||
|
||||
@@ -59,15 +59,31 @@ class TouchpadGestures(
|
||||
private var moverLastX = 0f
|
||||
private var moverLastY = 0f
|
||||
private var holdDrag = false
|
||||
private var clicked = 0
|
||||
private val longPress = Runnable {
|
||||
held = true
|
||||
dragging = true
|
||||
listener.onLongPress()
|
||||
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
|
||||
}
|
||||
private val clickUp = Runnable { releaseClick() }
|
||||
|
||||
private fun click(button: Int) {
|
||||
releaseClick()
|
||||
listener.onButton(button, true, x, y)
|
||||
clicked = button
|
||||
handler.postDelayed(clickUp, CLICK_MS)
|
||||
}
|
||||
|
||||
private fun releaseClick() {
|
||||
handler.removeCallbacks(clickUp)
|
||||
if (clicked != 0) listener.onButton(clicked, false, x, y)
|
||||
clicked = 0
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
handler.removeCallbacks(longPress)
|
||||
releaseClick()
|
||||
if (dragging) listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
|
||||
dragging = false
|
||||
held = false
|
||||
@@ -85,6 +101,7 @@ class TouchpadGestures(
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
releaseClick()
|
||||
lastX = event.x; lastY = event.y
|
||||
downAt = event.eventTime
|
||||
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
|
||||
@@ -204,8 +221,7 @@ class TouchpadGestures(
|
||||
}
|
||||
// The second finger lifting quickly, with nothing scrolled: a right click.
|
||||
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
|
||||
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
|
||||
listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
|
||||
click(PointerGestures.BTN_RIGHT)
|
||||
secondWasTap = false
|
||||
}
|
||||
return true
|
||||
@@ -218,13 +234,11 @@ class TouchpadGestures(
|
||||
twoFingers -> {
|
||||
// Both fingers down and up together, no scroll: a right click.
|
||||
if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TAP_MS * 2) {
|
||||
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
|
||||
listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
|
||||
click(PointerGestures.BTN_RIGHT)
|
||||
}
|
||||
}
|
||||
!cancelled && travelled <= slopPx && event.eventTime - downAt < TAP_MS -> {
|
||||
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
|
||||
listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
|
||||
click(PointerGestures.BTN_LEFT)
|
||||
lastTapAt = event.eventTime
|
||||
}
|
||||
}
|
||||
@@ -239,6 +253,7 @@ class TouchpadGestures(
|
||||
companion object {
|
||||
private const val HOLD_MS = 500L
|
||||
private const val TAP_MS = 300L
|
||||
private const val CLICK_MS = 50L
|
||||
private const val DOUBLE_TAP_MS = 300L
|
||||
private const val SCROLL_STEP_PX = 36f
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
*/
|
||||
object BwrapSpawner {
|
||||
private const val TAG = "BwrapSpawner"
|
||||
const val SOCKET = "com.droiddeck.launcher.bwrap"
|
||||
fun socket(context: Context): String = "${context.packageName}.bwrap"
|
||||
private val seq = AtomicInteger()
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
|
||||
@@ -42,10 +42,11 @@ object BwrapSpawner {
|
||||
fun start(context: Context) {
|
||||
if (server != null) return
|
||||
val app = context.applicationContext
|
||||
val name = socket(app)
|
||||
val socket = try {
|
||||
LocalServerSocket(SOCKET)
|
||||
LocalServerSocket(name)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "cannot listen on $SOCKET", e)
|
||||
Log.e(TAG, "cannot listen on $name", e)
|
||||
return
|
||||
}
|
||||
server = socket
|
||||
|
||||
@@ -110,6 +110,9 @@ object DesktopCatalog {
|
||||
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
|
||||
}
|
||||
|
||||
/** The installed desktop's size on disk; off the main thread. */
|
||||
fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context)
|
||||
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
|
||||
DesktopRemoval.cleanLegacy(context, listener)
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@ internal object DesktopRemoval {
|
||||
private fun protected(context: Context, root: File) =
|
||||
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
|
||||
|
||||
/**
|
||||
* What the desktop's own files take on disk, from the same inventory its removal uses (each
|
||||
* file's size); the bundled apps' and emulators' shared files are not counted. Reads an asset:
|
||||
* call it off the main thread.
|
||||
*/
|
||||
fun installedSize(context: Context): Long =
|
||||
lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
|
||||
|
||||
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
|
||||
@@ -51,6 +51,7 @@ object GuestCommand {
|
||||
"/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}",
|
||||
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
|
||||
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
cmd += argv
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
@@ -79,7 +80,14 @@ object ComponentsManager {
|
||||
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
|
||||
|
||||
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
|
||||
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
|
||||
data class Component(
|
||||
val detected: String,
|
||||
val detail: String,
|
||||
val inUse: String,
|
||||
val selected: String,
|
||||
val activeFile: String?,
|
||||
val queued: String?,
|
||||
)
|
||||
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
|
||||
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
|
||||
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
|
||||
@@ -129,23 +137,121 @@ object ComponentsManager {
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
|
||||
private fun loadState(context: Context): JSONObject =
|
||||
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
|
||||
private fun pendingStateFile(context: Context) = File(dataDir(context), "state.pending.json")
|
||||
|
||||
private fun loadState(context: Context): JSONObject {
|
||||
recoverState(context)
|
||||
return runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
|
||||
if (!has("active")) put("active", JSONObject())
|
||||
if (!has("queued")) put("queued", JSONObject())
|
||||
if (!has("profiles")) put("profiles", JSONObject())
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeStateFile(file: File, text: String) {
|
||||
val tmp = File(file.parentFile, file.name + ".tmp")
|
||||
try {
|
||||
writeSynced(tmp, text)
|
||||
check(tmp.renameTo(file)) { "Could not save component selections: ${file.name}" }
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeSynced(file: File, text: String) {
|
||||
FileOutputStream(file).use {
|
||||
it.write(text.toByteArray(Charsets.UTF_8))
|
||||
it.fd.sync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoverState(context: Context) {
|
||||
val pending = pendingStateFile(context)
|
||||
if (!pending.exists()) return
|
||||
val transaction = JSONObject(pending.readText())
|
||||
val state = transaction.getJSONObject("state")
|
||||
val expected = transaction.getString("launchSha256")
|
||||
val published = File(launchDir(context), "desired.tsv")
|
||||
val staged = File(launchDir(context), "desired.tsv.tmp")
|
||||
// The launch-table rename is the commit point. Never read stale app selections after it.
|
||||
if (!staged.exists() && published.isFile && Hashes.sha256(published) == expected) {
|
||||
writeStateFile(stateFile(context), state.toString(2))
|
||||
Log.i(TAG, "recovered published component selections")
|
||||
} else {
|
||||
Log.i(TAG, "discarded unpublished component selections")
|
||||
}
|
||||
check(pending.delete()) { "Could not finish component selection recovery" }
|
||||
if (staged.exists()) check(staged.delete()) { "Could not remove unpublished component selections" }
|
||||
}
|
||||
|
||||
private fun saveState(context: Context, state: JSONObject) {
|
||||
val f = stateFile(context)
|
||||
val tmp = File(f.parentFile, f.name + ".tmp")
|
||||
tmp.writeText(state.toString(2))
|
||||
tmp.renameTo(f)
|
||||
// Every change of choice reaches the launch wrappers at once.
|
||||
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
val prepared = prepareLaunchState(context, state)
|
||||
val dir = launchDir(context)
|
||||
val tmp = File(dir, "desired.tsv.tmp")
|
||||
try {
|
||||
writeSynced(tmp, prepared.text)
|
||||
writeStateFile(pendingStateFile(context), JSONObject()
|
||||
.put("state", state)
|
||||
.put("launchSha256", Hashes.sha256(tmp))
|
||||
.toString())
|
||||
check(tmp.renameTo(File(dir, "desired.tsv"))) { "Could not publish component selections" }
|
||||
writeStateFile(stateFile(context), state.toString(2))
|
||||
check(pendingStateFile(context).delete()) { "Could not finish saving component selections" }
|
||||
} finally {
|
||||
if (!pendingStateFile(context).exists()) tmp.delete()
|
||||
}
|
||||
File(dir, "store").listFiles()?.filter { it.isDirectory && it.name !in prepared.keep }
|
||||
?.forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
|
||||
|
||||
// Keep launch-time restore rows after the last per-game selection is removed.
|
||||
private fun rememberProfiledComponent(state: JSONObject, comp: String) {
|
||||
val profiled = state.optJSONArray("profiledComponents")?.let { values ->
|
||||
(0 until values.length()).mapNotNull { values.optString(it).takeIf(COMPONENTS::contains) }
|
||||
}.orEmpty().toMutableSet()
|
||||
if (profiled.add(comp)) state.put("profiledComponents", JSONArray(profiled.sorted()))
|
||||
}
|
||||
|
||||
fun gameComponents(context: Context, scope: String): Map<String, String> = synchronized(lock) {
|
||||
val profile = loadState(context).sub("profiles").optJSONObject(scope) ?: return emptyMap()
|
||||
COMPONENTS.mapNotNull { comp -> profile.optString(comp).takeIf(String::isNotEmpty)?.let { comp to it } }.toMap()
|
||||
}
|
||||
|
||||
fun setGameComponent(context: Context, scope: String, comp: String, file: String?) = synchronized(lock) {
|
||||
require(scope.matches(Regex("[1-9][0-9]{0,9}")) && scope.toLong() <= 4294967295L)
|
||||
require(comp in COMPONENTS)
|
||||
val state = loadState(context)
|
||||
val profiles = state.sub("profiles")
|
||||
val profile = profiles.optJSONObject(scope) ?: JSONObject().also { profiles.put(scope, it) }
|
||||
if (file == null) {
|
||||
if (profile.optString(comp).isNotEmpty()) rememberProfiledComponent(state, comp)
|
||||
profile.remove(comp)
|
||||
if (profile.length() == 0) profiles.remove(scope)
|
||||
} else {
|
||||
val name = safeName(file)
|
||||
val wcp = File(packagesDir(context), name)
|
||||
require(packageInfo(context, wcp).comp == comp)
|
||||
for (proton in protons(context)) ensureOriginal(context, proton, comp, state)
|
||||
rememberProfiledComponent(state, comp)
|
||||
profile.put(comp, name)
|
||||
}
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
fun clearGameComponents(context: Context, scope: String) = synchronized(lock) {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val state = loadState(context)
|
||||
val profiles = state.sub("profiles")
|
||||
profiles.optJSONObject(scope)?.let { profile ->
|
||||
COMPONENTS.filter { profile.optString(it).isNotEmpty() }
|
||||
.forEach { rememberProfiledComponent(state, it) }
|
||||
}
|
||||
profiles.remove(scope)
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
/** Takes over the Decky plugin's packages, originals and swap records, once. */
|
||||
private fun migratePlugin(context: Context) {
|
||||
val marker = File(dataDir(context), ".plugin-imported")
|
||||
@@ -220,24 +326,33 @@ object ComponentsManager {
|
||||
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
|
||||
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
|
||||
*/
|
||||
fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
|
||||
fun inUse(context: Context, proton: Proton): Boolean =
|
||||
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = false) }
|
||||
|
||||
/**
|
||||
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
|
||||
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
|
||||
*/
|
||||
fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
|
||||
fun gameRunning(context: Context, proton: Proton): Boolean =
|
||||
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = true) }
|
||||
|
||||
private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean {
|
||||
val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath))
|
||||
.map { it.trimEnd('/') + "/" }
|
||||
internal fun processUsesProton(paths: List<String>, text: String, gameOnly: Boolean): Boolean =
|
||||
paths.any { path ->
|
||||
val needle = path.trimEnd('/') + "/"
|
||||
if (gameOnly) text.contains(needle + "proton waitforexitandrun")
|
||||
else text.contains(needle + "proton ") || text.contains(needle + "files/bin")
|
||||
}
|
||||
|
||||
private fun anyProcess(context: Context, proton: Proton, match: (List<String>, String) -> Boolean): Boolean {
|
||||
val guestPaths = listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath)
|
||||
val paths = (guestPaths + guestPaths.map { host(context, it).absolutePath } + proton.dir.absolutePath).distinct()
|
||||
val procs = File("/proc").listFiles() ?: return false
|
||||
for (p in procs) {
|
||||
if (!p.name.all(Char::isDigit)) continue
|
||||
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
|
||||
if (cmd.isEmpty()) continue
|
||||
val text = String(cmd).replace('\u0000', ' ')
|
||||
if (needles.any { match(it, text) }) return true
|
||||
if (match(paths, text)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -261,7 +376,7 @@ object ComponentsManager {
|
||||
private fun compFiles(dir: File, comp: String): List<String> {
|
||||
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
|
||||
val base = File(dir, COMP_DIR.getValue(comp))
|
||||
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
|
||||
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).invariantSeparatorsPath }.sorted().toList()
|
||||
}
|
||||
|
||||
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
|
||||
@@ -291,9 +406,11 @@ object ComponentsManager {
|
||||
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
|
||||
}
|
||||
|
||||
/** The stored packages, newest version first. */
|
||||
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
|
||||
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
|
||||
?.filter { it.isFile && it.name.endsWith(".wcp") }
|
||||
?.mapNotNull { f -> runCatching { packageInfo(context, f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
|
||||
?.sortedWith(newestFirst { it.version.ifEmpty { it.file } })
|
||||
?: emptyList()
|
||||
|
||||
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
|
||||
@@ -352,7 +469,7 @@ object ComponentsManager {
|
||||
tar.closeArchiveEntry()
|
||||
}
|
||||
}
|
||||
tmp.renameTo(dest)
|
||||
check(tmp.renameTo(dest)) { "Could not save original bundle ${dest.name}" }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ originals
|
||||
@@ -380,8 +497,18 @@ object ComponentsManager {
|
||||
if (dest.isFile) return
|
||||
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
if (active != null && active.optString("protonVersion") == p.version) return
|
||||
val published = File(launchDir(context), "desired.tsv")
|
||||
if (published.isFile) {
|
||||
val mayHaveOverride = published.useLines { rows ->
|
||||
rows.any { row ->
|
||||
val fields = row.split('\t')
|
||||
fields.size == 7 && fields[0] != "*" && fields[1] == p.dir.name &&
|
||||
fields[2] == p.version && fields[3] == comp
|
||||
}
|
||||
}
|
||||
check(!mayHaveOverride) { context.getString(R.string.cmgr_original_missing) }
|
||||
}
|
||||
val rels = compFiles(p.dir, comp)
|
||||
if (rels.isEmpty()) return
|
||||
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val profile = JSONObject()
|
||||
.put("type", TYPE_OF.getValue(comp))
|
||||
@@ -400,7 +527,7 @@ object ComponentsManager {
|
||||
for (p in protons(context)) for (comp in COMPONENTS) {
|
||||
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
|
||||
}
|
||||
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
runCatching { saveState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ view
|
||||
@@ -431,9 +558,14 @@ object ComponentsManager {
|
||||
else context.getString(R.string.cmgr_changed_outside)
|
||||
}
|
||||
}
|
||||
val selectedLabel = when {
|
||||
active == null || active.optString("protonVersion") != p.version ->
|
||||
context.getString(R.string.comp_tag_original)
|
||||
else -> originalLabel(context, active) ?: active.optString("label", active.optString("file"))
|
||||
}
|
||||
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
Component(
|
||||
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
|
||||
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel, selectedLabel,
|
||||
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
|
||||
q?.let { originalLabel(context, it) ?: it.optString("label") },
|
||||
)
|
||||
@@ -455,64 +587,111 @@ object ComponentsManager {
|
||||
|
||||
// ------------------------------------------------------------------ launch-time enforcement
|
||||
|
||||
private data class PreparedLaunchState(val text: String, val keep: Set<String>)
|
||||
|
||||
/**
|
||||
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
|
||||
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
|
||||
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
|
||||
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
|
||||
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
|
||||
* what overwrote files in the first place.
|
||||
* Stages what each Proton should use for the launch wrappers. General rows use scope `*`;
|
||||
* per-game rows use the game's compatdata id and replace General for that component. Deletion
|
||||
* rows remove managed DLLs absent from the chosen package, so switching games cannot leave files
|
||||
* from the previous game's component set behind.
|
||||
*/
|
||||
private fun syncLaunchState(context: Context, state: JSONObject) {
|
||||
private fun prepareLaunchState(context: Context, state: JSONObject): PreparedLaunchState {
|
||||
val dir = launchDir(context)
|
||||
val store = File(dir, "store").apply { mkdirs() }
|
||||
val all = protons(context)
|
||||
val lines = StringBuilder()
|
||||
val keep = HashSet<String>()
|
||||
val active = state.sub("active")
|
||||
for (pid in active.keys()) {
|
||||
val p = all.firstOrNull { it.id == pid } ?: continue
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val a = comps.optJSONObject(comp) ?: continue
|
||||
if (a.optString("protonVersion") != p.version) continue
|
||||
val file = a.optString("file")
|
||||
val (key, wcp) = if (file.startsWith("original ")) {
|
||||
val v = file.removePrefix("original ")
|
||||
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
|
||||
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
|
||||
if (!wcp.isFile) continue
|
||||
val unpacked = File(store, key)
|
||||
if (!File(unpacked, ".complete").isFile) {
|
||||
unpacked.deleteRecursively()
|
||||
readWcp(wcp) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
|
||||
val out = File(unpacked, rel)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { tar.copyTo(it) }
|
||||
}
|
||||
|
||||
fun unpack(key: String, wcp: File, comp: String): File {
|
||||
check(wcp.isFile) { context.getString(R.string.cmgr_not_stored) }
|
||||
val unpacked = File(store, key)
|
||||
if (!File(unpacked, ".complete").isFile) {
|
||||
check(packageInfo(context, wcp).comp == comp) { "Wrong component in ${wcp.name}" }
|
||||
unpacked.deleteRecursively()
|
||||
check(unpacked.mkdirs()) { "Could not stage component bundle ${wcp.name}" }
|
||||
readWcp(wcp) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
|
||||
val out = File(unpacked, rel)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { tar.copyTo(it) }
|
||||
}
|
||||
File(unpacked, ".complete").writeText("1\n")
|
||||
}
|
||||
keep += key
|
||||
val files = a.optJSONObject("files") ?: continue
|
||||
for (rel in files.keys()) {
|
||||
val src = File(unpacked, rel)
|
||||
if (!src.isFile) continue
|
||||
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
|
||||
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
|
||||
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
|
||||
}
|
||||
File(unpacked, ".complete").writeText("1\n")
|
||||
}
|
||||
keep += key
|
||||
return unpacked
|
||||
}
|
||||
|
||||
fun managed(comp: String): Set<String> = if (comp == "fex") FEX_FILES.toSet() else buildSet {
|
||||
for (arch in PE_ARCHES) for (dll in COMP_DLLS.getValue(comp)) add("${COMP_DIR.getValue(comp)}/$arch/$dll")
|
||||
}
|
||||
|
||||
fun append(scope: String, p: Proton, comp: String, key: String, wcp: File) {
|
||||
val unpacked = unpack(key, wcp, comp)
|
||||
val files = unpacked.let { base ->
|
||||
File(base, "files").walkTopDown().filter { it.isFile }.map { it.relativeTo(base).invariantSeparatorsPath }
|
||||
.filter { owned(comp, it) }.associateWith { File(base, it) }
|
||||
}
|
||||
for (rel in managed(comp) - files.keys) {
|
||||
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
|
||||
.append('\t').append(rel).append("\t-\t-\n")
|
||||
}
|
||||
for ((rel, src) in files) {
|
||||
val guestSrc = "/" + src.relativeTo(root(context)).invariantSeparatorsPath
|
||||
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
|
||||
.append('\t').append(rel).append('\t').append(Hashes.sha256(src)).append('\t').append(guestSrc).append('\n')
|
||||
}
|
||||
}
|
||||
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
|
||||
val list = File(dir, "desired.tsv")
|
||||
val tmp = File(dir, "desired.tsv.tmp")
|
||||
tmp.writeText(lines.toString())
|
||||
tmp.renameTo(list)
|
||||
|
||||
fun choice(p: Proton, comp: String, entry: JSONObject): Pair<String, File>? {
|
||||
val file = entry.optString("file")
|
||||
return if (file.startsWith("original ")) {
|
||||
val version = safeName(file.removePrefix("original "))
|
||||
"original-${p.id}-$version-$comp" to File(originalsDir(context), "${p.id}/$version/$comp.wcp")
|
||||
} else {
|
||||
safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
|
||||
}.takeIf { it.second.isFile }
|
||||
}
|
||||
|
||||
val profiles = state.sub("profiles")
|
||||
val profiledComponents = buildSet {
|
||||
state.optJSONArray("profiledComponents")?.let { stored ->
|
||||
for (index in 0 until stored.length()) {
|
||||
stored.optString(index).takeIf(COMPONENTS::contains)?.let(::add)
|
||||
}
|
||||
}
|
||||
for (scope in profiles.keys()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
COMPONENTS.filterTo(this) { profile.optString(it).isNotEmpty() }
|
||||
}
|
||||
}
|
||||
val active = state.sub("active")
|
||||
for (p in all) for (comp in COMPONENTS) {
|
||||
val entry = active.optJSONObject(p.id)?.optJSONObject(comp)
|
||||
val selected = entry?.takeIf { it.optString("protonVersion") == p.version }?.let { choice(p, comp, it) }
|
||||
if (selected != null) append("*", p, comp, selected.first, selected.second)
|
||||
else if (comp in profiledComponents) {
|
||||
ensureOriginal(context, p, comp, state)
|
||||
val original = originalFile(context, p, comp)
|
||||
check(original.isFile) { context.getString(R.string.cmgr_original_missing) }
|
||||
append("*", p, comp, "original-${p.id}-${safeName(p.version)}-$comp", original)
|
||||
}
|
||||
}
|
||||
for (scope in profiles.keys()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
for (comp in COMPONENTS) {
|
||||
val file = profile.optString(comp)
|
||||
if (file.isEmpty()) continue
|
||||
val wcp = File(packagesDir(context), file)
|
||||
if (!wcp.isFile) continue
|
||||
val key = safeName(file.removeSuffix(".wcp"))
|
||||
for (p in all) append(scope, p, comp, key, wcp)
|
||||
}
|
||||
}
|
||||
return PreparedLaunchState(lines.toString(), keep)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ actions
|
||||
@@ -615,6 +794,11 @@ object ComponentsManager {
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
|
||||
}
|
||||
val profiles = state.sub("profiles")
|
||||
for (scope in profiles.keys()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
|
||||
}
|
||||
val f = File(packagesDir(context), name)
|
||||
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
|
||||
f.delete()
|
||||
@@ -670,10 +854,14 @@ object ComponentsManager {
|
||||
val arr = o.optJSONArray("items") ?: JSONArray()
|
||||
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
|
||||
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
|
||||
}.sortedWith(compareBy({ it.comp }, { it.release }, { it.file }))
|
||||
}.sortedWith(compareBy<CatalogItem> { it.comp }.then(newestFirst { it.file }))
|
||||
return Catalog(items, o.optLong("fetchedAt"))
|
||||
}
|
||||
|
||||
/** By the version in [of], highest first: the newest builds lead a list, as they are what is picked. */
|
||||
private fun <T> newestFirst(of: (T) -> String): Comparator<T> =
|
||||
Comparator { a, b -> ProtonExtras.compareVersions(of(b), of(a)) }
|
||||
|
||||
/**
|
||||
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
|
||||
* means every model in [families]. A row the user already chose in Components is left alone;
|
||||
|
||||
@@ -24,6 +24,12 @@ object CrashHandler {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
||||
try {
|
||||
Log.e(TAG, "uncaught on ${thread.name}", error)
|
||||
if (QuestExperimental.isSupported()) runCatching {
|
||||
File(context.filesDir, "quest-last-crash.txt").writeText(
|
||||
"${java.time.Instant.now()} thread=${thread.name}\n" +
|
||||
StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
|
||||
)
|
||||
}
|
||||
val dir = SessionPaths.current()
|
||||
if (dir != null && dir.isDirectory) {
|
||||
val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
|
||||
|
||||
@@ -13,6 +13,7 @@ object GameEnvironmentStore {
|
||||
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
|
||||
/** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */
|
||||
const val DXVK_CONFIG = "dxvkConfig"
|
||||
const val DXVK_CONFIG_GAMES = "dxvkConfigGames"
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): GameEnvironment.Config {
|
||||
@@ -57,6 +58,20 @@ object GameEnvironmentStore {
|
||||
publish(context, config)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setGameFexPreset(context: Context, scope: String, preset: String?): GameEnvironment.Config {
|
||||
val config = GameEnvironment.withGameFexPreset(read(context), scope, preset)
|
||||
save(context, config)
|
||||
return config
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setGameNoSteam(context: Context, scope: String, on: Boolean): GameEnvironment.Config {
|
||||
val config = GameEnvironment.withGameNoSteam(read(context), scope, on)
|
||||
save(context, config)
|
||||
return config
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
|
||||
@@ -68,6 +83,21 @@ object GameEnvironmentStore {
|
||||
TextureFiltering.autoLodBias(SessionState.upscaleRatio),
|
||||
)
|
||||
if (dxvk.isNotEmpty()) json.put(DXVK_CONFIG, dxvk)
|
||||
val autoBias = TextureFiltering.autoLodBias(SessionState.upscaleRatio)
|
||||
val generalAnisotropy = SessionPrefs.textureAnisotropy(context)
|
||||
val generalLodBias = SessionPrefs.textureLodBias(context)
|
||||
val gameDxvk = JSONObject()
|
||||
SessionPrefs.gameTextureFiltering(context).forEach { (scope, choice) ->
|
||||
gameDxvk.put(
|
||||
scope,
|
||||
TextureFiltering.dxvkOptions(
|
||||
choice.anisotropy ?: generalAnisotropy,
|
||||
choice.lodBias ?: generalLodBias,
|
||||
autoBias,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal class GameProfileFollowState {
|
||||
var followsSteam = true
|
||||
private set
|
||||
|
||||
fun selectManually() {
|
||||
followsSteam = false
|
||||
}
|
||||
|
||||
fun resumeFollowingSteam() {
|
||||
followsSteam = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal class GameProfileInventoryCache<T : Any> {
|
||||
private val lock = Any()
|
||||
|
||||
private var revision: Long? = null
|
||||
private var value: List<T>? = null
|
||||
|
||||
// Called only by the refresh worker; the UI invalidates through request revisions.
|
||||
fun get(requestedRevision: Long, load: () -> List<T>): List<T> = synchronized(lock) {
|
||||
value?.takeIf { revision == requestedRevision } ?: load().toList().also {
|
||||
value = it
|
||||
revision = requestedRevision
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
|
||||
object GameProfileManager {
|
||||
@Synchronized
|
||||
fun reset(context: Context, scope: String) {
|
||||
require(scope.isNotEmpty() && GameEnvironment.validScope(scope))
|
||||
val failures = mutableListOf<Pair<String, Throwable>>()
|
||||
fun clear(name: String, action: () -> Unit) {
|
||||
runCatching(action).onFailure { failures += name to it }
|
||||
}
|
||||
|
||||
clear("environment") {
|
||||
GameEnvironmentStore.save(
|
||||
context,
|
||||
GameEnvironmentStore.read(context).withEntries(scope, emptyMap()),
|
||||
)
|
||||
}
|
||||
clear("Proton") { ProtonDefault.requestGame(context, scope, null) }
|
||||
clear("component packages") { ComponentsManager.clearGameComponents(context, scope) }
|
||||
clear("texture filtering") { SessionPrefs.clearGameTextureFiltering(context, scope) }
|
||||
clear("Windows components") { WinComponents.setPicks(context, scope, emptyList()) }
|
||||
|
||||
if (failures.isNotEmpty()) {
|
||||
val names = failures.joinToString { it.first }
|
||||
throw IllegalStateException("Could not clear: $names", failures.first().second)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal data class GameProfileRefreshState<T>(
|
||||
val data: T? = null,
|
||||
val loaded: Boolean = false,
|
||||
val failed: Boolean = false,
|
||||
) {
|
||||
fun succeeded(data: T) = GameProfileRefreshState(data, loaded = true)
|
||||
|
||||
fun failed() = copy(failed = true)
|
||||
|
||||
fun retrying() = copy(failed = false)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal class LatestRequestCoordinator<T> {
|
||||
data class Request<T>(val generation: Long, val value: T)
|
||||
|
||||
private val lock = Any()
|
||||
private var generation = 0L
|
||||
private var running = false
|
||||
private var latest: Request<T>? = null
|
||||
private var pending: Request<T>? = null
|
||||
|
||||
fun submit(value: T): Boolean = synchronized(lock) {
|
||||
if (latest?.value != value) generation++
|
||||
pending = Request(generation, value).also { latest = it }
|
||||
if (running) {
|
||||
false
|
||||
} else {
|
||||
running = true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun takeLatest(): Request<T>? = synchronized(lock) {
|
||||
pending.also {
|
||||
pending = null
|
||||
if (it == null) running = false
|
||||
}
|
||||
}
|
||||
|
||||
fun isLatest(request: Request<T>): Boolean = synchronized(lock) {
|
||||
request.generation == latest?.generation
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.session
|
||||
import android.app.Activity
|
||||
import android.app.GameManager
|
||||
import android.app.GameState
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
@@ -16,8 +18,9 @@ import android.util.Log
|
||||
* a level the device can hold indefinitely rather than holding a floor, which cost games and
|
||||
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
|
||||
* the game;
|
||||
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
|
||||
* 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode
|
||||
* - the panel's fastest mode at its current size (Android 6+), up to the user's peak refresh rate
|
||||
* ([peakRefreshCap]): a 120 Hz phone is switched to 120 Hz for the menu, not only when a game
|
||||
* votes for it. With a frame cap, the fastest mode
|
||||
* the cap divides evenly, so every capped frame is shown for the same number of refreshes;
|
||||
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
|
||||
* its boost on loading screens or menus keeps it up.
|
||||
@@ -36,8 +39,11 @@ object PerfMode {
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay
|
||||
if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else {
|
||||
val cur = display.mode
|
||||
val cap = peakRefreshCap(a)
|
||||
val sameSize = display.supportedModes
|
||||
.filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight }
|
||||
.filter { it.refreshRate <= cap + 0.5f }
|
||||
.ifEmpty { display.supportedModes.filter { it.modeId == cur.modeId } }
|
||||
val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate }
|
||||
?: sameSize.maxByOrNull { it.refreshRate }
|
||||
if (best != null && best.modeId != cur.modeId &&
|
||||
@@ -68,6 +74,20 @@ object PerfMode {
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest rate Android will run the panel at: the user's "peak refresh rate" (Smooth display,
|
||||
* or the AYN/Odin quick-settings tile). Above it a mode can be requested and is even reported back
|
||||
* as active, while SurfaceFlinger keeps composing at the cap. The Thor set to 60 Hz ran 120 Hz
|
||||
* sessions at 60: gamescope and Steam were told 120, every game topped out at 60, and the QAM's
|
||||
* Frame Limit read "60 FPS (120 Hz)" and changed nothing. Unset (most phones) is no cap.
|
||||
*/
|
||||
fun peakRefreshCap(context: Context): Float {
|
||||
val peak = try {
|
||||
Settings.System.getFloat(context.contentResolver, "peak_refresh_rate")
|
||||
} catch (_: Throwable) { 0f }
|
||||
return if (peak >= 1f) peak else Float.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */
|
||||
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
|
||||
if (fpsLimit <= 0) return true
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -9,10 +10,12 @@ import java.io.File
|
||||
object ProtonDefault {
|
||||
private const val TAG = "ProtonDefault"
|
||||
private const val DIR = "root/.local/share/droiddeck-compat"
|
||||
private const val GAMES = "games.json"
|
||||
|
||||
data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean)
|
||||
data class Request(val seq: Long, val valve: Boolean, val dir: String)
|
||||
data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List<Tool>)
|
||||
data class GameChoice(val valve: Boolean, val dir: String)
|
||||
|
||||
enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE }
|
||||
|
||||
@@ -60,6 +63,33 @@ object ProtonDefault {
|
||||
return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id
|
||||
}
|
||||
|
||||
internal fun parseGames(text: String?): Map<String, GameChoice?> {
|
||||
val root = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return emptyMap()
|
||||
if (root.optInt("version", 0) != 1) return emptyMap()
|
||||
val games = root.optJSONObject("games") ?: return emptyMap()
|
||||
return buildMap {
|
||||
games.keys().forEach { id ->
|
||||
if (!GameEnvironment.validScope(id)) return@forEach
|
||||
if (games.isNull(id)) {
|
||||
put(id, null)
|
||||
} else {
|
||||
val value = games.optJSONObject(id) ?: return@forEach
|
||||
val gameDir = value.optString("dir")
|
||||
val valve = value.opt("valve")
|
||||
if (gameDir.isNotEmpty() && valve is Boolean) put(id, GameChoice(valve, gameDir))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun gameChoice(context: Context, scope: String): GameChoice? =
|
||||
parseGames(read(File(dir(context), GAMES)))[scope]
|
||||
|
||||
fun gameSelectedId(context: Context, scope: String, protons: List<ComponentsManager.Proton>): String? {
|
||||
val pick = gameChoice(context, scope) ?: return null
|
||||
return protons.firstOrNull { it.valve == pick.valve && it.dir.name == pick.dir }?.id
|
||||
}
|
||||
|
||||
internal fun nextSeq(now: Long, state: State, previous: Request?): Long =
|
||||
maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1)
|
||||
|
||||
@@ -83,4 +113,33 @@ object ProtonDefault {
|
||||
val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name }
|
||||
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val d = dir(context)
|
||||
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
|
||||
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
|
||||
val games = JSONObject()
|
||||
choices.forEach { (id, choice) ->
|
||||
games.put(id, choice?.let { JSONObject().put("valve", it.valve).put("dir", it.dir) } ?: JSONObject.NULL)
|
||||
}
|
||||
val staged = File(d, "$GAMES.staged")
|
||||
try {
|
||||
d.mkdirs()
|
||||
staged.writeText(JSONObject().put("version", 1).put("games", games).toString(1) + "\n")
|
||||
if (!staged.renameTo(File(d, GAMES))) throw java.io.IOException("rename failed")
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not write the per-game Proton request", e)
|
||||
throw e
|
||||
}
|
||||
val state = state(context)
|
||||
val tool = proton?.let { selected -> state.tools.firstOrNull { it.valve == selected.valve && it.dir == selected.dir.name } }
|
||||
return if (SessionState.running && state.live && (proton == null || tool != null && state.pending != tool.name)) {
|
||||
Outcome.LIVE
|
||||
} else {
|
||||
Outcome.NEXT_START
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,15 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Immediate download and installation of the optional ARM64 Proton builds. */
|
||||
object ProtonExtras {
|
||||
private const val TAG = "ProtonExtras"
|
||||
private const val NEED_BYTES = 4L * 1024 * 1024 * 1024
|
||||
private const val RELEASES = "https://api.github.com/repos/%s/releases?per_page=15"
|
||||
/** How long a release check stands before the next one goes to GitHub. */
|
||||
const val CHECK_EVERY_MS = 6L * 60 * 60 * 1000
|
||||
@Volatile
|
||||
var installInProgress = false
|
||||
private set
|
||||
@@ -23,23 +26,122 @@ object ProtonExtras {
|
||||
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
|
||||
|
||||
/** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */
|
||||
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
|
||||
internal data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
|
||||
val sha256: String? = null)
|
||||
|
||||
val tools = listOf(
|
||||
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
|
||||
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "The412Banner/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
)
|
||||
|
||||
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
|
||||
private fun requests(context: Context) = File(home(context), ".bl-proton-extra")
|
||||
private fun toolsDir(context: Context) = File(home(context), ".local/share/Steam/compatibilitytools.d")
|
||||
/** The release tag each build was installed from, by directory name. */
|
||||
private fun releasesFile(context: Context) = File(context.filesDir, "proton-extras.json")
|
||||
/** The newest release seen per source, and when it was looked up. */
|
||||
private fun latestFile(context: Context) = File(context.filesDir, "proton-latest.json")
|
||||
|
||||
/** The installed build's directory name (e.g. GE-Proton11-7), or null. */
|
||||
fun installed(context: Context, tool: Tool): String? =
|
||||
toolsDir(context).listFiles()
|
||||
/** The installed build's directory name (e.g. GE-Proton11-7), the newest when there are several, or null. */
|
||||
fun installed(context: Context, tool: Tool): String? = installedIn(toolsDir(context), tool).lastOrNull()
|
||||
|
||||
/** Every installed build's directory name, oldest first. */
|
||||
fun installedAll(context: Context, tool: Tool): List<String> = installedIn(toolsDir(context), tool)
|
||||
|
||||
internal fun installedIn(dir: File, tool: Tool): List<String> =
|
||||
dir.listFiles()
|
||||
?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile }
|
||||
?.maxByOrNull { it.name }?.name
|
||||
?.map { it.name }
|
||||
?.sortedWith { a, b -> compareVersions(a, b) }
|
||||
?: emptyList()
|
||||
|
||||
// Architecture names carry digits (aarch64, x86_64) that are not part of a version.
|
||||
private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64")
|
||||
private val NUMBER = Regex("\\d+")
|
||||
|
||||
/** The numbers in a release tag or build folder, in order: GE-Proton11-10 is 11, 10. */
|
||||
internal fun versionKey(name: String): List<Long> =
|
||||
NUMBER.findAll(ARCH.replace(name, " ")).mapNotNull { it.value.toLongOrNull() }.toList()
|
||||
|
||||
/** Orders by those numbers, so 11-10 is after 11-9; the name itself only breaks a tie. */
|
||||
internal fun compareVersions(a: String, b: String): Int {
|
||||
val x = versionKey(a)
|
||||
val y = versionKey(b)
|
||||
for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return x[i].compareTo(y[i])
|
||||
return if (x.size != y.size) x.size.compareTo(y.size) else a.compareTo(b)
|
||||
}
|
||||
|
||||
/** [latest] when it is newer than every installed release, else null. Nothing installed is not an update. */
|
||||
internal fun updateFor(installed: List<String>, latest: String?): String? {
|
||||
if (latest == null || installed.isEmpty()) return null
|
||||
val newest = installed.maxWithOrNull { a, b -> compareVersions(a, b) } ?: return null
|
||||
val x = versionKey(latest)
|
||||
val y = versionKey(newest)
|
||||
for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return if (x[i] > y[i]) latest else null
|
||||
return if (x.size > y.size) latest else null
|
||||
}
|
||||
|
||||
/** The release each installed build came from: the tag recorded at install, else its folder name. */
|
||||
fun installedTags(context: Context, tool: Tool): List<String> {
|
||||
val recorded = readJson(releasesFile(context))
|
||||
return installedAll(context, tool).map { recorded.optString(it).ifEmpty { it } }
|
||||
}
|
||||
|
||||
/** A newer release than the installed one, from the last check (no network), or null. */
|
||||
fun update(context: Context, tool: Tool): String? = updateFor(installedTags(context, tool), cachedLatest(context, tool))
|
||||
|
||||
fun cachedLatest(context: Context, tool: Tool): String? =
|
||||
readJson(latestFile(context)).optJSONObject(tool.id)?.optString("tag")?.ifEmpty { null }
|
||||
|
||||
/** Its own lock, not the install's: a check must not wait behind a download. */
|
||||
private val checkLock = Any()
|
||||
/** A failed look-up (offline) is not repeated on every resume, only after this long. */
|
||||
private const val RETRY_MS = 15L * 60 * 1000
|
||||
private var lastAttempt = 0L
|
||||
|
||||
/**
|
||||
* Looks up each source's newest release unless it was looked up within [CHECK_EVERY_MS] (or
|
||||
* [force]). Offline or rate limited, the last answer stands. True when anything was asked.
|
||||
*/
|
||||
fun checkLatest(context: Context, force: Boolean = false, now: Long = System.currentTimeMillis()): Boolean {
|
||||
synchronized(checkLock) {
|
||||
val file = latestFile(context)
|
||||
val cache = readJson(file)
|
||||
val due = tools.filter { tool ->
|
||||
val at = cache.optJSONObject(tool.id)?.optLong("at", 0L) ?: 0L
|
||||
force || (now - at) !in 0 until CHECK_EVERY_MS
|
||||
}
|
||||
if (due.isEmpty() || !force && (now - lastAttempt) in 0 until RETRY_MS) return false
|
||||
lastAttempt = now
|
||||
for (tool in due) {
|
||||
val tag = findLatestAsset(tool)?.tag ?: continue
|
||||
cache.put(tool.id, JSONObject().put("tag", tag).put("at", now))
|
||||
}
|
||||
runCatching { file.writeText(cache.toString()) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun record(context: Context, tool: Tool, dir: String?, tag: String) {
|
||||
runCatching {
|
||||
val latest = readJson(latestFile(context))
|
||||
val known = latest.optJSONObject(tool.id)?.optString("tag")
|
||||
if (known.isNullOrEmpty() || compareVersions(tag, known) >= 0) {
|
||||
latest.put(tool.id, JSONObject().put("tag", tag).put("at", System.currentTimeMillis()))
|
||||
latestFile(context).writeText(latest.toString())
|
||||
}
|
||||
if (dir != null) {
|
||||
val releases = readJson(releasesFile(context))
|
||||
val present = toolsDir(context).list()?.toSet() ?: emptySet()
|
||||
releases.keys().asSequence().toList().filter { it !in present }.forEach { releases.remove(it) }
|
||||
releasesFile(context).writeText(releases.put(dir, tag).toString())
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "record ${tool.id} $tag", it) }
|
||||
}
|
||||
|
||||
private fun readJson(file: File): JSONObject =
|
||||
runCatching { JSONObject(FileUtils.readString(file) ?: "{}") }.getOrDefault(JSONObject())
|
||||
|
||||
/** A request left by an earlier app version, still consumed by the session shim. */
|
||||
fun queued(context: Context, tool: Tool): Boolean =
|
||||
@@ -52,9 +154,15 @@ object ProtonExtras {
|
||||
else file.writeText(lines.joinToString("\n") + "\n")
|
||||
}
|
||||
|
||||
/** Deletes the installed build; Steam discovers its absence on its next start. */
|
||||
fun remove(context: Context, tool: Tool) {
|
||||
installed(context, tool)?.let { File(toolsDir(context), it).deleteRecursively() }
|
||||
/** Deletes one installed build ([dir], else the newest); Steam discovers its absence on its next start. */
|
||||
fun remove(context: Context, tool: Tool, dir: String? = null) {
|
||||
val name = if (dir != null) dir.takeIf { it in installedAll(context, tool) } ?: return else installed(context, tool) ?: return
|
||||
File(toolsDir(context), name).deleteRecursively()
|
||||
}
|
||||
|
||||
/** Deletes every build but the newest. */
|
||||
fun removeOlder(context: Context, tool: Tool) {
|
||||
installedAll(context, tool).dropLast(1).forEach { File(toolsDir(context), it).deleteRecursively() }
|
||||
}
|
||||
|
||||
/** Downloads, verifies and registers the latest build without waiting for another session. */
|
||||
@@ -145,9 +253,12 @@ object ProtonExtras {
|
||||
hostEnv["PROOT_TMP_DIR"] = context.cacheDir.path
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { hostEnv["LD_LIBRARY_PATH"] = it }
|
||||
val child = process.start()
|
||||
var placed: String? = null
|
||||
child.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
Log.i(TAG, line)
|
||||
// "droiddeck-proton-extra: installed <folder>", the folder as the tarball names it.
|
||||
line.substringAfter("droiddeck-proton-extra: installed ", "").takeIf { it.isNotEmpty() }?.let { placed = it }
|
||||
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
|
||||
onProgress(installing, -1)
|
||||
}
|
||||
@@ -158,6 +269,7 @@ object ProtonExtras {
|
||||
else {
|
||||
archive.delete()
|
||||
unqueue(context, tool)
|
||||
record(context, tool, placed, asset.tag)
|
||||
if (EsyncPacks.enabled(context)) {
|
||||
onProgress(context.getString(R.string.esync_fetching), -1)
|
||||
try {
|
||||
@@ -177,32 +289,36 @@ object ProtonExtras {
|
||||
private fun findLatestAsset(tool: Tool): Asset? {
|
||||
val releases = Downloader.downloadString(RELEASES.format(tool.repo)) ?: return null
|
||||
return try {
|
||||
val array = JSONArray(releases)
|
||||
for (i in 0 until array.length()) {
|
||||
val release = array.getJSONObject(i)
|
||||
if (release.optBoolean("draft", false)) continue
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
val entries = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||
val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue
|
||||
val name = archive.optString("name")
|
||||
val stem = name.substringBefore(".tar")
|
||||
val checksum = entries.firstOrNull {
|
||||
it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true)
|
||||
}?.optString("browser_download_url")?.takeIf { it.startsWith("http") }
|
||||
return Asset(
|
||||
release.optString("tag_name"), name,
|
||||
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
|
||||
archive.optLong("size", 0L),
|
||||
Hashes.githubSha256(archive.optString("digest")),
|
||||
)
|
||||
}
|
||||
null
|
||||
pickAsset(tool, JSONArray(releases))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release metadata for ${tool.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest full release in GitHub's list (newest first) that carries [tool]'s ARM64 archive. */
|
||||
internal fun pickAsset(tool: Tool, array: JSONArray): Asset? {
|
||||
for (i in 0 until array.length()) {
|
||||
val release = array.getJSONObject(i)
|
||||
if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
val entries = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||
val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue
|
||||
val name = archive.optString("name")
|
||||
val stem = name.substringBefore(".tar")
|
||||
val checksum = entries.firstOrNull {
|
||||
it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true)
|
||||
}?.optString("browser_download_url")?.takeIf { it.startsWith("http") }
|
||||
return Asset(
|
||||
release.optString("tag_name"), name,
|
||||
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
|
||||
archive.optLong("size", 0L),
|
||||
Hashes.githubSha256(archive.optString("digest")),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun requestLines(context: Context): List<String> =
|
||||
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import java.io.File
|
||||
|
||||
/** Device-detected defaults and launch evidence for experimental Quest compatibility. */
|
||||
object QuestExperimental {
|
||||
private const val TAG = "QuestExperimental"
|
||||
const val DRIVER = "turnip25.1.0-quest1"
|
||||
|
||||
// Quest 2 has an A6xx GPU and cannot use this bundled A7xx driver.
|
||||
internal fun supported(model: String, device: String, manufacturer: String): Boolean =
|
||||
model.equals("Quest 3", ignoreCase = true) || model.equals("Quest 3S", ignoreCase = true) ||
|
||||
((manufacturer.equals("Oculus", ignoreCase = true) || manufacturer.equals("Meta", ignoreCase = true)) &&
|
||||
(device == "eureka" || device == "panther"))
|
||||
|
||||
fun isSupported(): Boolean = supported(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (!isSupported()) return
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
if (!prefs.getBoolean("questDefaultsV1", false)) {
|
||||
// Auto would download an unwrapped display driver and lose the compatibility path.
|
||||
// Apply once so deliberate changes made during headset testing remain possible.
|
||||
prefs.edit()
|
||||
.putString("gpuDriverMode", SessionPrefs.GPU_DRIVERS_MANUAL)
|
||||
.putString("androidDriver", DRIVER)
|
||||
.putString("linuxDriver", "")
|
||||
// The existing HDR option uses SurfaceControl presentation. This is a
|
||||
// corruption workaround from upstream issue #245, not a claim of HDR accuracy.
|
||||
.putBoolean("hdr.steam", true)
|
||||
.putBoolean("questDefaultsV1", true)
|
||||
.apply()
|
||||
}
|
||||
val report = buildString {
|
||||
appendLine("DroidDeck Quest Experimental ${BuildConfig.VERSION_NAME}")
|
||||
appendLine("package=${context.packageName}")
|
||||
appendLine("manufacturer=${Build.MANUFACTURER} model=${Build.MODEL}")
|
||||
appendLine("device=${Build.DEVICE} board=${Build.BOARD} hardware=${Build.HARDWARE}")
|
||||
appendLine("android=${Build.VERSION.RELEASE} sdk=${Build.VERSION.SDK_INT}")
|
||||
appendLine("abis=${Build.SUPPORTED_ABIS.joinToString()}")
|
||||
for (name in listOf("gpu_model", "gpu_chipid")) {
|
||||
val value = runCatching { File("/sys/class/kgsl/kgsl-3d0/$name").readText().trim() }
|
||||
.getOrDefault("unavailable")
|
||||
appendLine("$name=$value")
|
||||
}
|
||||
appendLine("driverMode=${SessionPrefs.gpuDriverMode(context)}")
|
||||
appendLine("androidDriver=${SessionPrefs.androidDriver(context)}")
|
||||
appendLine("steamSurfaceControlWorkaround=${SessionPrefs.hdr(context, "steam")}")
|
||||
appendLine("GPUOBJ fallback installed in Android driver and ARM64 libblsession")
|
||||
appendLine("Experimental Quest 3/3S compatibility; 2D panel application")
|
||||
}
|
||||
Log.i(TAG, report)
|
||||
runCatching { File(context.filesDir, "quest-startup.txt").writeText(report) }
|
||||
.onFailure { Log.w(TAG, "Could not write startup report", it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
|
||||
data class ProfileValue(val value: String, val inherited: Boolean)
|
||||
|
||||
data class SelectedGameProfile(
|
||||
val appId: Long,
|
||||
val name: String,
|
||||
val fexPreset: ProfileValue,
|
||||
val proton: ProfileValue,
|
||||
val protonId: String?,
|
||||
val components: Map<String, ProfileValue>,
|
||||
val componentFiles: Map<String, String>,
|
||||
val environmentOverrides: Int,
|
||||
val windowsComponentNames: List<String>,
|
||||
/** Start without Steam (GameEnvironment.NO_STEAM) is on for this game. */
|
||||
val startWithoutSteam: Boolean = false,
|
||||
) {
|
||||
val hasOverrides: Boolean
|
||||
get() = !fexPreset.inherited ||
|
||||
!proton.inherited ||
|
||||
components.values.any { !it.inherited } ||
|
||||
environmentOverrides > 0 ||
|
||||
windowsComponentNames.isNotEmpty() ||
|
||||
startWithoutSteam
|
||||
|
||||
companion object {
|
||||
fun resolve(
|
||||
selectedAppId: Long,
|
||||
games: List<Library.SteamGame>,
|
||||
generalFexPreset: String,
|
||||
environment: GameEnvironment.Config,
|
||||
snapshot: ComponentsManager.Snapshot,
|
||||
generalProtonId: String?,
|
||||
gameProtonChoice: ProtonDefault.GameChoice?,
|
||||
gameProtonId: String?,
|
||||
gameComponents: Map<String, String>,
|
||||
gameWindowsComponents: List<String>,
|
||||
): SelectedGameProfile? {
|
||||
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
|
||||
val fexOverride = GameEnvironment.gameFexPreset(environment, game.profileKey)
|
||||
val selectedProtonId = if (gameProtonChoice == null) generalProtonId else gameProtonId
|
||||
val selectedProton = snapshot.protons.firstOrNull { it.proton.id == selectedProtonId }
|
||||
val packages = snapshot.packages.associateBy { it.file }
|
||||
val components = ComponentsManager.COMPONENTS.associateWith { component ->
|
||||
val override = gameComponents[component]
|
||||
ProfileValue(
|
||||
value = override?.let { packages[it]?.version ?: it }
|
||||
?: selectedProton?.components?.get(component)?.selected.orEmpty(),
|
||||
inherited = override == null,
|
||||
)
|
||||
}
|
||||
// Start without Steam is a switch of its own on the Games page, listed by name.
|
||||
val otherEnvironment = environment.games[game.profileKey].orEmpty().keys - FexPreset.environmentKeys -
|
||||
GameEnvironment.NO_STEAM
|
||||
return SelectedGameProfile(
|
||||
appId = selectedAppId,
|
||||
name = game.name,
|
||||
fexPreset = ProfileValue(fexOverride ?: generalFexPreset, fexOverride == null),
|
||||
proton = ProfileValue(
|
||||
selectedProton?.proton?.name ?: gameProtonChoice?.dir.orEmpty(),
|
||||
gameProtonChoice == null,
|
||||
),
|
||||
protonId = selectedProtonId,
|
||||
components = components,
|
||||
componentFiles = gameComponents,
|
||||
environmentOverrides = otherEnvironment.size,
|
||||
windowsComponentNames = gameWindowsComponents.map(WinComponentNames::of),
|
||||
startWithoutSteam = GameEnvironment.gameNoSteam(environment, game.profileKey),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
/** An active game needs a live window; a launch URL or stale Steam base layer is insufficient. */
|
||||
internal object SessionAchievementGame {
|
||||
fun activeAppId(focused: Long?, windows: List<Long>, installed: Set<Long>): Long? {
|
||||
if (focused != null && focused > 0 && focused != 769L) {
|
||||
return focused.takeIf { it in windows && it in installed }
|
||||
}
|
||||
return windows.filter { it != 769L && it in installed }.distinct().singleOrNull()
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
|
||||
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
|
||||
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
"usr/local/bin/droiddeck-netmanager" to "usr/local/bin/droiddeck-netmanager",
|
||||
"usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1",
|
||||
@@ -86,6 +87,8 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url",
|
||||
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
|
||||
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
|
||||
// Cardboard mode's SteamVR: install, start and stop (docs/cardboard-vr.md).
|
||||
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
|
||||
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
|
||||
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
|
||||
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
|
||||
@@ -132,6 +135,18 @@ object SessionFiles {
|
||||
val msitools = listOf("msiinfo", "cabextract", "7z", "7z.so", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
|
||||
.map { "usr/local/lib/droiddeck-msitools/$it" } +
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
// Cardboard mode's SteamVR driver and the Vulkan layer its compositor needs (tools/vr).
|
||||
val vr = listOf(
|
||||
"droiddeck/bin/linuxarm64/driver_droiddeck.so",
|
||||
"droiddeck/driver.vrdrivermanifest",
|
||||
"droiddeck/resources/settings/default.vrsettings",
|
||||
"layer/libVkLayer_droiddeck_vr.so",
|
||||
"layer/VK_LAYER_DROIDDECK_vr.json",
|
||||
).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } +
|
||||
// The same layer, implicit for the whole session: it passes everything through until SteamVR
|
||||
// runs, then Steam's own web helper gets the shared-texture support it needs (tools/vr).
|
||||
listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to
|
||||
"usr/share/vulkan/implicit_layer.d/droiddeck_vr.json")
|
||||
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
|
||||
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
|
||||
@@ -139,7 +154,7 @@ 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 + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
|
||||
) + 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)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.CpuAllocationDefaults
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.gpu.Cardboard
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import org.json.JSONObject
|
||||
|
||||
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
|
||||
object SessionPrefs {
|
||||
data class GameTextureFiltering(val anisotropy: Int? = null, val lodBias: String? = null)
|
||||
|
||||
const val SUSPEND_AUTO = "auto"
|
||||
const val SUSPEND_NATIVE = "native"
|
||||
const val SUSPEND_MANUAL = "manual"
|
||||
@@ -68,15 +73,7 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("animations", on).apply()
|
||||
}
|
||||
|
||||
/** The Flathub Store (beta): its rail item. Off by default. */
|
||||
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
|
||||
|
||||
fun setStoreEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("storeEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
|
||||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
|
||||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
|
||||
|
||||
fun setGameStoresEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
|
||||
@@ -276,26 +273,34 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client's own core pick is overridden. When on, BL_CLIENT_CPUS is sent even when
|
||||
* it names every core - unlike a game mask, the point here is to undo a pin Steam applies to
|
||||
* itself, and the scheduler's default is exactly what Steam's choice takes away.
|
||||
*/
|
||||
fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false)
|
||||
internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
|
||||
|
||||
/** Defaults are read only for absent keys; even a saved empty/all-core choice wins. */
|
||||
internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
|
||||
val saved = prefs(context)
|
||||
return CoreAllocation(
|
||||
saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
|
||||
saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
|
||||
saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Use the device default until the user saves an override or a client mask. */
|
||||
fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
|
||||
|
||||
fun setClientCpusOverride(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
|
||||
}
|
||||
|
||||
/** Comma-separated core list for the client (taskset -c syntax); "" = every core. */
|
||||
fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "") ?: ""
|
||||
fun clientCpus(context: Context): String = coreAllocation(context).clientCpus
|
||||
|
||||
fun setClientCpus(context: Context, list: String) {
|
||||
prefs(context).edit().putString("clientCpus", list).apply()
|
||||
}
|
||||
|
||||
/** Comma-separated core list for games (taskset -c syntax); "" = every core = nothing sent. */
|
||||
fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "") ?: ""
|
||||
/** Comma-separated core list for games (taskset -c syntax); "" = every core (explicitly restored when the client is pinned). */
|
||||
fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
|
||||
|
||||
fun setGameCpus(context: Context, list: String) {
|
||||
prefs(context).edit().putString("gameCpus", list).apply()
|
||||
@@ -841,6 +846,31 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("screenEffects", effects.encode().toString()).apply()
|
||||
}
|
||||
|
||||
/** The key [cardboard] lives under; SessionActivity watches it so a change applies live. */
|
||||
const val CARDBOARD_KEY = "cardboard"
|
||||
|
||||
/** Cardboard mode (gpu/Cardboard) as last set; off by default. */
|
||||
fun cardboard(context: Context): Cardboard {
|
||||
val text = prefs(context).getString(CARDBOARD_KEY, null) ?: return Cardboard.OFF
|
||||
return runCatching { Cardboard.decode(JSONObject(text)) }.getOrDefault(Cardboard.OFF)
|
||||
}
|
||||
|
||||
fun setCardboard(context: Context, cardboard: Cardboard) {
|
||||
prefs(context).edit().putString(CARDBOARD_KEY, cardboard.encode().toString()).apply()
|
||||
}
|
||||
|
||||
/** The Cardboard picture (Cardboard.SOURCE_*) to go back to once SteamVR stops; -1 = none (it was
|
||||
* not switched for SteamVR). Kept across activity restarts, so a SteamVR session that outlives the
|
||||
* activity still hands the picture back. */
|
||||
fun cardboardSourceBeforeSteamVr(context: Context): Int = prefs(context).getInt("cardboardSourceBeforeSteamVr", -1)
|
||||
|
||||
fun setCardboardSourceBeforeSteamVr(context: Context, source: Int) {
|
||||
prefs(context).edit().putInt("cardboardSourceBeforeSteamVr", source).apply()
|
||||
}
|
||||
|
||||
/** The prefs file [cardboard] lives in, for a change listener. */
|
||||
fun sessionPrefs(context: Context): android.content.SharedPreferences = prefs(context)
|
||||
|
||||
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
|
||||
|
||||
/** [textureAnisotropyChoices] labelled in the app's language. */
|
||||
@@ -882,6 +912,74 @@ object SessionPrefs {
|
||||
publishGameEnvironment(context)
|
||||
}
|
||||
|
||||
fun gameTextureFiltering(context: Context, scope: String): GameTextureFiltering {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
return gameTextureFiltering(context)[scope] ?: GameTextureFiltering()
|
||||
}
|
||||
|
||||
fun gameTextureFiltering(context: Context): Map<String, GameTextureFiltering> {
|
||||
val json = runCatching {
|
||||
JSONObject(prefs(context).getString("gameTextureFiltering", null) ?: "{}")
|
||||
}.getOrElse { JSONObject() }
|
||||
return buildMap {
|
||||
json.keys().forEach { scope ->
|
||||
if (!GameEnvironment.validScope(scope)) return@forEach
|
||||
val entry = json.optJSONObject(scope) ?: return@forEach
|
||||
val anisotropy = entry.optInt("anisotropy").takeIf {
|
||||
entry.has("anisotropy") && it in TextureFiltering.ANISOTROPY
|
||||
}
|
||||
val lodBias = entry.optString("lodBias").takeIf {
|
||||
entry.has("lodBias") && it in TextureFiltering.LOD_BIAS
|
||||
}
|
||||
if (anisotropy != null || lodBias != null) {
|
||||
put(scope, GameTextureFiltering(anisotropy, lodBias))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setGameTextureAnisotropy(context: Context, scope: String, value: Int?) {
|
||||
require(value == null || value in TextureFiltering.ANISOTROPY)
|
||||
updateGameTextureFiltering(context, scope) { it.copy(anisotropy = value) }
|
||||
}
|
||||
|
||||
fun setGameTextureLodBias(context: Context, scope: String, choice: String?) {
|
||||
require(choice == null || choice in TextureFiltering.LOD_BIAS)
|
||||
updateGameTextureFiltering(context, scope) { it.copy(lodBias = choice) }
|
||||
}
|
||||
|
||||
fun clearGameTextureFiltering(context: Context, scope: String) {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val choices = gameTextureFiltering(context).toMutableMap()
|
||||
if (choices.remove(scope) == null) return
|
||||
saveGameTextureFiltering(context, choices)
|
||||
publishGameEnvironment(context)
|
||||
}
|
||||
|
||||
private fun updateGameTextureFiltering(
|
||||
context: Context,
|
||||
scope: String,
|
||||
change: (GameTextureFiltering) -> GameTextureFiltering,
|
||||
) {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val choices = gameTextureFiltering(context).toMutableMap()
|
||||
val next = change(choices[scope] ?: GameTextureFiltering())
|
||||
if (next.anisotropy == null && next.lodBias == null) choices.remove(scope) else choices[scope] = next
|
||||
saveGameTextureFiltering(context, choices)
|
||||
publishGameEnvironment(context)
|
||||
}
|
||||
|
||||
private fun saveGameTextureFiltering(context: Context, choices: Map<String, GameTextureFiltering>) {
|
||||
val json = JSONObject()
|
||||
choices.forEach { (id, choice) ->
|
||||
json.put(id, JSONObject().apply {
|
||||
choice.anisotropy?.let { put("anisotropy", it) }
|
||||
choice.lodBias?.let { put("lodBias", it) }
|
||||
})
|
||||
}
|
||||
prefs(context).edit().putString("gameTextureFiltering", json.toString()).apply()
|
||||
}
|
||||
|
||||
/** Hands the change to the next game launch (GameEnvironmentStore); the running game keeps its own. */
|
||||
private fun publishGameEnvironment(context: Context) {
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
|
||||
@@ -334,6 +334,7 @@ class SessionService : Service() {
|
||||
SessionFiles.stage(this, root)
|
||||
com.droiddeck.launcher.agent.AgentGuest.reset(this)
|
||||
// The compat tool asks for an Epic game's sign-in code at every launch, the client's Play button included.
|
||||
com.droiddeck.launcher.stores.gog.GogComet.begin()
|
||||
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
|
||||
|
||||
val sessionDir = openSessionFolder()
|
||||
@@ -381,7 +382,12 @@ class SessionService : Service() {
|
||||
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
|
||||
SessionState.deckPad = false
|
||||
deckBinds = emptyList()
|
||||
if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir)
|
||||
if (controllersOn) {
|
||||
addControllerEnvironment(guest, fakeInputDir, sessionDir)
|
||||
// Steam enumerates /dev/input while it starts. Make every attached controller's
|
||||
// discovery node exist after the rings and guest environment are ready.
|
||||
SessionState.padBridge?.syncConnectedControllers()
|
||||
}
|
||||
// The user's own games, for the runtime's shortcuts writer to put in the client's library
|
||||
// before the client starts (see frontend/AddedGames and droiddeck-steam-shortcuts).
|
||||
if (steamHere) {
|
||||
@@ -579,6 +585,8 @@ class SessionService : Service() {
|
||||
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean) {
|
||||
guest.add("/usr/bin/env")
|
||||
guest.add("-i")
|
||||
// PRoot executes guest[0] directly; assignments belong after env -i.
|
||||
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
|
||||
guest.add("HOME=/root")
|
||||
guest.add("USER=root")
|
||||
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
|
||||
@@ -661,16 +669,15 @@ class SessionService : Service() {
|
||||
extraEnv().forEach { guest.add(it) }
|
||||
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
|
||||
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
|
||||
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
|
||||
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
|
||||
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
|
||||
// sent only when it is a real restriction - a game has no pin of its own to undo.
|
||||
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
|
||||
// A game launched by pinned Steam inherits its mask. Explicitly restore all game cores
|
||||
// when requested, as well as applying restricted game choices in the Proton wrapper.
|
||||
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
|
||||
if (clientPinned) {
|
||||
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
|
||||
}
|
||||
// The game cores also pin the desktop and a program from the rail; without a choice the
|
||||
// session script picks every core but the slowest cluster for those (program_cores).
|
||||
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
|
||||
CpuCores.gameLaunchList(SessionPrefs.gameCpus(this), clientPinned)
|
||||
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
|
||||
}
|
||||
|
||||
@@ -1536,6 +1543,7 @@ class SessionService : Service() {
|
||||
}
|
||||
updateDownloadMonitoring()
|
||||
val stoppedGen = sessionGen
|
||||
val stoppedSession = SessionState.sessionId
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
GpuClockPin.stop(this)
|
||||
@@ -1582,13 +1590,14 @@ class SessionService : Service() {
|
||||
if (prootPid > 1 || auxiliary.isNotEmpty()) {
|
||||
Thread({
|
||||
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
|
||||
com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession)
|
||||
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
|
||||
if (prootPid > 1) teardown(prootPid)
|
||||
killGuestLeftovers()
|
||||
finishSessionStop(status, stoppedGen)
|
||||
}, "session-teardown").start()
|
||||
} else {
|
||||
finishSessionStop(status, stoppedGen)
|
||||
Thread({ com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession); finishSessionStop(status, stoppedGen) }, "gog-session-stop").start()
|
||||
}
|
||||
}
|
||||
if (prootPid > 1) acquireLocks()
|
||||
|
||||
@@ -48,6 +48,9 @@ object StoreState {
|
||||
private set
|
||||
var searching by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** An app whose Flathub page was asked for from outside the store (its card on Desktop), once. */
|
||||
var openRequest by mutableStateOf<String?>(null)
|
||||
private var searchSeq = 0
|
||||
|
||||
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Session-owned unlocks; history reads and upload acknowledgements never create notifications. */
|
||||
object AchievementNotifications {
|
||||
data class Unlock(val client: String, val id: String, val name: String, val description: String, val icon: String?, val game: String)
|
||||
|
||||
private var session: String? = null
|
||||
private val seen = HashSet<String>()
|
||||
var pending by mutableStateOf<List<Unlock>>(emptyList())
|
||||
private set
|
||||
|
||||
fun begin(owner: String?) = StoresState.post {
|
||||
session = owner
|
||||
seen.clear()
|
||||
pending = emptyList()
|
||||
}
|
||||
|
||||
fun end(owner: String?) = StoresState.post {
|
||||
if (session == owner) {
|
||||
session = null
|
||||
seen.clear()
|
||||
pending = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun unlock(owner: String, unlock: Unlock) = StoresState.post {
|
||||
if (session == owner && seen.add("${unlock.client}:${unlock.id}")) {
|
||||
pending = (pending + unlock).take(16)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss(unlock: Unlock) {
|
||||
if (pending.firstOrNull() === unlock) pending = pending.drop(1)
|
||||
}
|
||||
|
||||
/** Only presentation fields from the owned daemon, bounded before entering Compose. */
|
||||
fun parse(event: JSONObject, game: String): Unlock? {
|
||||
val client = event.optString("client_id")
|
||||
val id = event.optString("id")
|
||||
val name = event.optString("name").trim()
|
||||
if (!client.matches(Regex("[0-9]{1,32}")) || id.isBlank() || id.length > 256 || name.isEmpty()) return null
|
||||
val icon = event.optString("icon").takeIf { it.length <= 2048 && it.startsWith("https://") }
|
||||
return Unlock(client, id, name.take(256), event.optString("description").take(1024), icon, game.take(256))
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ object CloudSaves {
|
||||
val galaxy = GogAuth.validToken(context)
|
||||
val clientId = gogClientId(context, id, folder)
|
||||
if (userId.isEmpty() || galaxy == null || clientId == null) null
|
||||
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId, account?.optString("refresh_token").orEmpty()) ?: galaxy)
|
||||
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId) ?: galaxy)
|
||||
}
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context)
|
||||
@@ -148,17 +148,9 @@ object CloudSaves {
|
||||
}
|
||||
|
||||
/** GOG cloud storage wants a token issued to the game's own client: the Galaxy refresh token, exchanged with the game's id and secret. */
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String, refresh: String): String? {
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
val secret = GogPrefs.get(context).getString("client_secret_$id", null)?.ifEmpty { null } ?: return null
|
||||
if (refresh.isEmpty()) return null
|
||||
return try {
|
||||
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
|
||||
val c = URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(secret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}").openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000; c.readTimeout = 15_000
|
||||
c.setRequestProperty("User-Agent", "GOG Galaxy")
|
||||
if (c.responseCode != 200) { c.disconnect(); return null }
|
||||
JSONObject(c.inputStream.bufferedReader().readText()).optString("access_token").ifEmpty { null }.also { c.disconnect() }
|
||||
} catch (e: Exception) { null }
|
||||
return GogAuth.gameToken(context, clientId, secret)?.accessToken
|
||||
}
|
||||
|
||||
// ---- sync -----------------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* What other sites know of a store game, looked up by its title: the Metacritic score (as Steam's
|
||||
* store search carries it) and HowLongToBeat's times. Neither store publishes these, so every
|
||||
* lookup is a name match against someone else's catalog, kept on disk a fortnight (a miss for two
|
||||
* days) and in memory for the process. Blocking: call off the main thread.
|
||||
*/
|
||||
object GameFacts {
|
||||
private const val TAG = "GameFacts"
|
||||
private const val HIT_TTL_MS = 14L * 24 * 60 * 60 * 1000
|
||||
private const val MISS_TTL_MS = 2L * 24 * 60 * 60 * 1000
|
||||
private const val TIMEOUT_MS = 10_000
|
||||
|
||||
/** Metacritic's critic score (0-100) and the game's page there. */
|
||||
data class Metacritic(val score: Int, val url: String)
|
||||
|
||||
/** HowLongToBeat's community times, in seconds (0 when nobody has logged that kind of run), and the game's page there. */
|
||||
data class Hltb(val main: Int, val extras: Int, val completionist: Int, val url: String) {
|
||||
val isEmpty: Boolean get() = main <= 0 && extras <= 0 && completionist <= 0
|
||||
}
|
||||
|
||||
/** A remembered answer: [value] null is a remembered miss. */
|
||||
private class Entry<T>(val value: T?, val at: Long) {
|
||||
fun fresh(now: Long) = now - at < if (value != null) HIT_TTL_MS else MISS_TTL_MS
|
||||
}
|
||||
|
||||
private val memory = ConcurrentHashMap<String, Entry<*>>()
|
||||
|
||||
/** The score or the times already in memory, without touching disk or network; null when not yet known. */
|
||||
fun peekMetacritic(title: String): Lookup<Metacritic>? = peek("metacritic", title)
|
||||
fun peekHltb(title: String): Lookup<Hltb>? = peek("hltb", title)
|
||||
|
||||
/** The score, or null when Steam does not list the game or has no score for it. */
|
||||
fun metacritic(context: Context, title: String): Metacritic? =
|
||||
remembered(context, "metacritic", title,
|
||||
read = { o -> o.optInt("score").takeIf { it > 0 }?.let { Metacritic(it, o.optString("url").ifEmpty { metacriticSearch(title) }) } },
|
||||
write = { o, v -> o.put("score", v.score).put("url", v.url) },
|
||||
) { searchSteamScore(title) }
|
||||
|
||||
fun hltb(context: Context, title: String): Hltb? =
|
||||
remembered(context, "hltb", title,
|
||||
read = { o -> Hltb(o.optInt("main"), o.optInt("extras"), o.optInt("completionist"), o.optString("url").ifEmpty { hltbSearch(title) }).takeUnless { it.isEmpty } },
|
||||
write = { o, v -> o.put("main", v.main).put("extras", v.extras).put("completionist", v.completionist).put("url", v.url) },
|
||||
) { searchHltb(title) }
|
||||
|
||||
// Where a game goes when its own page is not known: the site's search for its name.
|
||||
private fun metacriticSearch(title: String) = "https://www.metacritic.com/search/${encodePath(TitleMatch.searchTerm(title))}/?category=13"
|
||||
private fun hltbSearch(title: String) = "$HLTB/?q=${URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8")}"
|
||||
private fun encodePath(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> peek(kind: String, title: String): Lookup<T>? {
|
||||
val e = memory[key(kind, title)] as Entry<T>? ?: return null
|
||||
return e.value?.let { Lookup.Found(it) } ?: Lookup.None
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory copy, else the disk copy while fresh, else [fetch]. [fetch] answers a [Fetched]:
|
||||
* a failed request is not a miss, so it leaves the stale copy (if any) standing.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> remembered(
|
||||
context: Context, kind: String, title: String,
|
||||
read: (JSONObject) -> T?, write: (JSONObject, T) -> Unit, fetch: () -> Fetched<T>,
|
||||
): T? {
|
||||
val now = System.currentTimeMillis()
|
||||
val k = key(kind, title)
|
||||
(memory[k] as Entry<T>?)?.takeIf { it.fresh(now) }?.let { return it.value }
|
||||
val file = File(context.filesDir, "game-facts/$kind/${fileName(title)}.json")
|
||||
val disk = runCatching { JSONObject(file.readText()) }.getOrNull()
|
||||
?.let { o -> Entry(if (o.optBoolean("none")) null else read(o), o.optLong("at")) }
|
||||
if (disk != null && disk.fresh(now)) { memory[k] = disk; return disk.value }
|
||||
return when (val got = fetch()) {
|
||||
is Fetched.Failed -> disk?.value
|
||||
is Fetched.Answer -> {
|
||||
val entry = Entry(got.value, now)
|
||||
memory[k] = entry
|
||||
runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
val o = JSONObject().put("at", now)
|
||||
if (got.value != null) write(o, got.value) else o.put("none", true)
|
||||
file.writeText(o.toString())
|
||||
}
|
||||
got.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface Fetched<out T> {
|
||||
object Failed : Fetched<Nothing>
|
||||
class Answer<T>(val value: T?) : Fetched<T>
|
||||
}
|
||||
|
||||
private fun key(kind: String, title: String) = "$kind:${TitleMatch.normalize(title)}"
|
||||
|
||||
private fun fileName(title: String) = TitleMatch.normalize(title).take(80).ifEmpty { "_" + title.hashCode().toUInt() }
|
||||
|
||||
// Steam's store search lists a game's Metacritic score beside its name: one request, no key.
|
||||
// The game's own Metacritic page is in its app details, asked for only once there is a score.
|
||||
private fun searchSteamScore(title: String): Fetched<Metacritic> {
|
||||
val term = URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8")
|
||||
val body = StoreNet.get("https://store.steampowered.com/api/storesearch/?l=english&cc=US&term=$term", timeoutMs = TIMEOUT_MS)
|
||||
?: return Fetched.Failed
|
||||
return runCatching {
|
||||
val items = JSONObject(body).optJSONArray("items") ?: JSONArray()
|
||||
val scored = (0 until items.length()).mapNotNull { items.optJSONObject(it) }
|
||||
.filter { it.optString("type") == "app" && (it.optString("metascore").toIntOrNull() ?: 0) > 0 }
|
||||
val best = TitleMatch.pick(title, scored) { listOf(it.optString("name")) }
|
||||
val score = best?.optString("metascore")?.toIntOrNull() ?: return Fetched.Answer(null)
|
||||
Fetched.Answer(Metacritic(score, metacriticPage(best.optLong("id")) ?: metacriticSearch(title)))
|
||||
}.getOrElse { Fetched.Failed }
|
||||
}
|
||||
|
||||
/** Steam's link to the game on Metacritic, without its tracking tag; null when Steam gives none. */
|
||||
private fun metacriticPage(appId: Long): String? {
|
||||
if (appId <= 0) return null
|
||||
val body = StoreNet.get("https://store.steampowered.com/api/appdetails?appids=$appId&filters=metacritic", timeoutMs = TIMEOUT_MS) ?: return null
|
||||
return runCatching {
|
||||
JSONObject(body).optJSONObject("$appId")?.optJSONObject("data")?.optJSONObject("metacritic")?.optString("url")
|
||||
?.substringBefore('?')?.takeIf { it.startsWith("https://") }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
// HowLongToBeat's own site search: a short-lived token from init, sent back as x-auth-token.
|
||||
// The token is bound to the User-Agent that asked for it, so both calls send the same one.
|
||||
private const val HLTB = "https://howlongtobeat.com"
|
||||
private val hltbHeaders = mapOf("Referer" to "$HLTB/", "Origin" to HLTB)
|
||||
@Volatile private var hltbToken: Pair<String, Long>? = null
|
||||
|
||||
private fun hltbToken(renew: Boolean): String? {
|
||||
val now = System.currentTimeMillis()
|
||||
hltbToken?.takeIf { !renew && now - it.second < 10 * 60 * 1000 }?.let { return it.first }
|
||||
val body = StoreNet.get("$HLTB/api/search/site/init?t=$now", timeoutMs = TIMEOUT_MS, headers = hltbHeaders) ?: return null
|
||||
val token = runCatching { JSONObject(body).optString("token") }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
|
||||
hltbToken = token to now
|
||||
return token
|
||||
}
|
||||
|
||||
private fun searchHltb(title: String): Fetched<Hltb> {
|
||||
val terms = JSONArray().apply { TitleMatch.searchTerm(title).split(' ').filter { it.isNotBlank() }.forEach { put(it) } }
|
||||
val request = JSONObject()
|
||||
.put("searchType", "games").put("searchTerms", terms).put("searchPage", 1).put("size", 20)
|
||||
.put("searchOptions", JSONObject()
|
||||
.put("games", JSONObject().put("userId", 0).put("platform", "").put("sortCategory", "popular").put("rangeCategory", "main")
|
||||
.put("rangeTime", JSONObject().put("min", 0).put("max", 0))
|
||||
.put("gameplay", JSONObject().put("perspective", "").put("flow", "").put("genre", ""))
|
||||
.put("year", "").put("modifier", ""))
|
||||
.put("users", JSONObject().put("sortCategory", "postcount"))
|
||||
.put("lists", JSONObject().put("sortCategory", "follows"))
|
||||
.put("filter", "").put("sort", 0).put("randomizer", 0))
|
||||
.put("useCache", true)
|
||||
.toString()
|
||||
// A token can lapse between init and search; one fresh one is worth a second try.
|
||||
var body: String? = null
|
||||
for (renew in listOf(false, true)) {
|
||||
val token = hltbToken(renew) ?: return Fetched.Failed
|
||||
body = StoreNet.postJson("$HLTB/api/search/site", request, timeoutMs = TIMEOUT_MS, headers = hltbHeaders + ("x-auth-token" to token))
|
||||
if (body != null) break
|
||||
}
|
||||
if (body == null) return Fetched.Failed
|
||||
return runCatching {
|
||||
val data = JSONObject(body).optJSONArray("data") ?: JSONArray()
|
||||
val games = (0 until data.length()).mapNotNull { data.optJSONObject(it) }
|
||||
.sortedByDescending { it.optString("game_type") == "game" }
|
||||
val best = TitleMatch.pick(title, games) { g -> listOf(g.optString("game_name")) + g.optString("game_alias").split(',') }
|
||||
Fetched.Answer(best?.let {
|
||||
val page = it.optInt("game_id").takeIf { id -> id > 0 }?.let { id -> "$HLTB/game/$id" } ?: hltbSearch(title)
|
||||
Hltb(it.optInt("comp_main"), it.optInt("comp_plus"), it.optInt("comp_100"), page)
|
||||
}?.takeUnless { it.isEmpty })
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "HowLongToBeat answer unreadable: ${it.javaClass.simpleName}")
|
||||
Fetched.Failed
|
||||
}
|
||||
}
|
||||
|
||||
/** A store's release date ("2015.05.19", "2015-05-19T00:00:00+02:00", ...) in the reader's own words, or null. */
|
||||
fun releaseLabel(raw: String): String? {
|
||||
val m = Regex("""^(\d{4})[.\-/](\d{2})[.\-/](\d{2})""").find(raw.trim()) ?: return null
|
||||
val (y, mo, d) = m.destructured
|
||||
// Epic dates a title it has no date for 2099-01-01.
|
||||
val date = runCatching { LocalDate.of(y.toInt(), mo.toInt(), d.toInt()) }.getOrNull()?.takeIf { it.year in 1970..2098 } ?: return null
|
||||
return date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
|
||||
}
|
||||
|
||||
/** Whole hours, with a half when the time is nearer one: "51½". Under an hour, null (the caller says minutes). */
|
||||
fun hours(seconds: Int): String? {
|
||||
if (seconds < 3600) return null
|
||||
val halves = Math.round(seconds / 1800.0).toInt()
|
||||
return "${halves / 2}" + if (halves % 2 == 1) "½" else ""
|
||||
}
|
||||
}
|
||||
|
||||
/** A fact looked up off the page: still asking, found, or not to be had. */
|
||||
sealed interface Lookup<out T> {
|
||||
object Pending : Lookup<Nothing>
|
||||
object None : Lookup<Nothing>
|
||||
data class Found<T>(val value: T) : Lookup<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two catalogs mean the same game. Store titles carry marks, editions and subtitles the
|
||||
* other site spells differently ("The Witcher 3: Wild Hunt" is Steam's "...Wild Hunt — Remastered");
|
||||
* a sequel is never the same game ("Hades" is not "Hades II").
|
||||
*/
|
||||
internal object TitleMatch {
|
||||
private val MARKS = Regex("[™®©]")
|
||||
private val EDITION = Regex(
|
||||
"(?i)[\\s:\\-–—]*\\b(?:(?:complete|definitive|legendary|deluxe|ultimate|enhanced|gold|premium|standard|special|anniversary|collector'?s|digital deluxe|game of the year|goty)\\s+edition|goty|director'?s cut)\\b",
|
||||
)
|
||||
// Where a subtitle starts: a colon or dash with space around it, or an opening bracket.
|
||||
private val SUBTITLE = Regex("\\s*[:–—]\\s*|\\s+-\\s+|\\s*\\(")
|
||||
|
||||
fun normalize(s: String): String = s.lowercase().replace(MARKS, "").replace("&", "and").replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
|
||||
fun stripEdition(s: String): String = s.replace(EDITION, " ").trim().ifEmpty { s }
|
||||
|
||||
/**
|
||||
* What to type into another site's search: the title without its marks, edition or subtitle
|
||||
* punctuation (Steam finds nothing for "Disco Elysium - The Final Cut", all of it for the words).
|
||||
*/
|
||||
fun searchTerm(title: String): String =
|
||||
stripEdition(title.replace(MARKS, "")).replace(Regex("\\s+[-–—]\\s+|[:–—()\\[\\]]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
/** 3 the same name, 2 the same but for the edition, 1 one is the other with a subtitle, 0 not the same game. */
|
||||
fun score(want: String, got: String): Int {
|
||||
val w = normalize(want)
|
||||
val g = normalize(got)
|
||||
if (w.isEmpty() || g.isEmpty()) return 0
|
||||
if (w == g) return 3
|
||||
val ws = normalize(stripEdition(want))
|
||||
val gs = normalize(stripEdition(got))
|
||||
if (ws == gs || ws == g || w == gs) return 2
|
||||
if (ws in heads(got) || gs in heads(want)) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
/** The title up to each place a subtitle could start, normalized. */
|
||||
private fun heads(title: String): Set<String> =
|
||||
SUBTITLE.findAll(title).map { normalize(title.substring(0, it.range.first)) }.filter { it.isNotEmpty() }.toSet()
|
||||
|
||||
/** The best-named of [items] in their order (the site's own ranking breaks a tie), or null when none is the game. */
|
||||
fun <T> pick(want: String, items: List<T>, names: (T) -> List<String>): T? {
|
||||
var best: T? = null
|
||||
var bestScore = 0
|
||||
for (item in items) {
|
||||
val s = names(item).filter { it.isNotBlank() }.maxOfOrNull { score(want, it.trim()) } ?: 0
|
||||
if (s > bestScore) { best = item; bestScore = s }
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.epic.EpicAchievements
|
||||
import com.droiddeck.launcher.stores.gog.GogAchievements
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* A source's achievements with the account's progress, the last good answer cached on disk. Blocking.
|
||||
* [source] is the library's id for where a game came from ("gog", "epic"; "steam" once it has one).
|
||||
*/
|
||||
abstract class StoreAchievements(val source: String, val label: String) {
|
||||
|
||||
/** [extra]: what the source needs to find the game (Epic: "namespace"). */
|
||||
data class Game(val source: String, val id: String, val extra: Map<String, String> = emptyMap()) {
|
||||
companion object {
|
||||
fun of(item: CatalogItem) = Game(item.store.id, item.id, item.extra)
|
||||
fun of(sidecar: StoreGameSidecar) = Game(sidecar.store.id, sidecar.id, sidecar.extra)
|
||||
}
|
||||
}
|
||||
|
||||
class Achievement(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val hidden: Boolean,
|
||||
val unlocked: Boolean,
|
||||
/** Unix seconds; 0 when locked or unknown. */
|
||||
val unlockedAt: Long,
|
||||
val icon: String?,
|
||||
val iconLocked: String?,
|
||||
)
|
||||
|
||||
sealed class Result {
|
||||
class Found(val list: List<Achievement>) : Result()
|
||||
class Failed(val reason: Reason) : Result()
|
||||
}
|
||||
|
||||
enum class Reason { SIGNED_OUT, UNREACHABLE, NO_SERVICE }
|
||||
|
||||
protected abstract fun request(context: Context, game: Game): Result
|
||||
|
||||
fun fetch(context: Context, game: Game): Result {
|
||||
val result = runCatching { request(context, game) }.getOrElse { e ->
|
||||
Log.w(TAG, "$source:${game.id}: ${e.javaClass.simpleName}")
|
||||
Result.Failed(Reason.UNREACHABLE)
|
||||
}
|
||||
if (result is Result.Found) runCatching {
|
||||
cacheFile(context, game).apply { parentFile?.mkdirs() }.writeText(toJson(result.list).toString())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun cached(context: Context, game: Game): List<Achievement>? = runCatching {
|
||||
val file = cacheFile(context, game)
|
||||
if (!file.exists()) null else fromJson(JSONArray(file.readText()))
|
||||
}.getOrNull()
|
||||
|
||||
private fun cacheFile(context: Context, game: Game) =
|
||||
File(context.filesDir, "achievements/$source/${game.id.replace(Regex("[^A-Za-z0-9._-]"), "_")}.json")
|
||||
|
||||
protected fun locale(): String = Locale.getDefault().toLanguageTag()
|
||||
|
||||
/** ISO-8601 (including GOG's "+0000" offsets) to Unix seconds; 0 if unreadable. */
|
||||
protected fun epochSeconds(iso: String?): Long {
|
||||
if (iso.isNullOrBlank()) return 0
|
||||
val normalized = iso.replace(Regex("([+-]\\d{2})(\\d{2})$"), "$1:$2")
|
||||
return runCatching { OffsetDateTime.parse(normalized).toEpochSecond() }
|
||||
.recoverCatching { Instant.parse(normalized).epochSecond }
|
||||
.getOrDefault(0)
|
||||
}
|
||||
|
||||
private fun toJson(list: List<Achievement>) = JSONArray().apply {
|
||||
for (a in list) put(
|
||||
JSONObject().put("id", a.id).put("name", a.name).put("description", a.description).put("hidden", a.hidden)
|
||||
.put("unlocked", a.unlocked).put("unlockedAt", a.unlockedAt).put("icon", a.icon ?: "").put("iconLocked", a.iconLocked ?: ""),
|
||||
)
|
||||
}
|
||||
|
||||
private fun fromJson(arr: JSONArray) = List(arr.length()) { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
Achievement(
|
||||
id = o.getString("id"), name = o.optString("name"), description = o.optString("description"),
|
||||
hidden = o.optBoolean("hidden"), unlocked = o.optBoolean("unlocked"), unlockedAt = o.optLong("unlockedAt"),
|
||||
icon = o.optString("icon").ifEmpty { null }, iconLocked = o.optString("iconLocked").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StoreAchievements"
|
||||
var revision by androidx.compose.runtime.mutableIntStateOf(0)
|
||||
private set
|
||||
fun invalidate() { revision++ }
|
||||
|
||||
private val all: List<StoreAchievements> by lazy { listOf(GogAchievements, EpicAchievements) }
|
||||
|
||||
/** Null for a source without achievements. */
|
||||
fun forSource(source: String?): StoreAchievements? = all.firstOrNull { it.source == source }
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ data class CatalogItem(
|
||||
/** The web product page: where "Get for free" and "View on <store>" land. */
|
||||
val storeUrl: String = "",
|
||||
val developer: String = "",
|
||||
val publisher: String = "",
|
||||
val releaseDate: String = "",
|
||||
val description: String = "",
|
||||
/** True for a title the signed-in account owns (a library entry). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -42,6 +43,7 @@ class StoreGameSidecar(
|
||||
val epic: EpicOptions = EpicOptions(),
|
||||
/** GOG / Epic cloud saves for this game: down before a launch, up after it (the game card's switch). */
|
||||
val cloud: Boolean = true,
|
||||
val achievements: Boolean = true,
|
||||
) {
|
||||
val isInstalled: Boolean get() = state == STATE_INSTALLED
|
||||
|
||||
@@ -52,8 +54,8 @@ class StoreGameSidecar(
|
||||
exe: String = this.exe, launcher: String? = this.launcher, args: List<String> = this.args,
|
||||
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
|
||||
installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero,
|
||||
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud,
|
||||
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud)
|
||||
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud, achievements: Boolean = this.achievements,
|
||||
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements)
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("version", VERSION)
|
||||
@@ -64,6 +66,7 @@ class StoreGameSidecar(
|
||||
if (state != STATE_INSTALLED) put("state", state)
|
||||
if (store == Store.EPIC) put("epic", JSONObject().put("eos", epic.eos).put("offline", epic.offline))
|
||||
if (store != Store.AMAZON) put("cloud", cloud)
|
||||
if (store == Store.GOG) put("achievements", achievements)
|
||||
if (!launcher.isNullOrEmpty()) put("launcher", launcher)
|
||||
if (args.isNotEmpty()) put("args", JSONArray(args))
|
||||
if (env.isNotEmpty()) put("env", JSONObject(env as Map<*, *>))
|
||||
@@ -90,6 +93,12 @@ class StoreGameSidecar(
|
||||
|
||||
fun file(folder: File): File = File(folder, FILE_NAME)
|
||||
|
||||
/** An installed game's sidecar; walks the install folders. */
|
||||
fun find(context: Context, store: Store, id: String): StoreGameSidecar? =
|
||||
StoreInstallRoot.gameFolders(context.applicationContext).asSequence()
|
||||
.mapNotNull { read(it) }
|
||||
.firstOrNull { it.store == store && it.id == id }
|
||||
|
||||
/**
|
||||
* Changes an Epic game's launch choices on disk: reads the sidecar fresh (never a copy held
|
||||
* by a screen), applies [change], writes it, and reads it back. The written sidecar, or null
|
||||
@@ -109,6 +118,12 @@ class StoreGameSidecar(
|
||||
return read(folder)?.takeIf { it.cloud == on }
|
||||
}
|
||||
|
||||
fun updateAchievements(folder: File, on: Boolean): StoreGameSidecar? {
|
||||
val current = read(folder) ?: return null
|
||||
current.copy(achievements = on).write(folder)
|
||||
return read(folder)?.takeIf { it.achievements == on }
|
||||
}
|
||||
|
||||
/** The sidecar in [folder], or null when there is none or it cannot be read. */
|
||||
fun read(folder: File): StoreGameSidecar? {
|
||||
val f = file(folder)
|
||||
@@ -146,6 +161,7 @@ class StoreGameSidecar(
|
||||
// An "overlay" (or "v") field from an earlier build is read past: there is no overlay.
|
||||
epic = o.optJSONObject("epic")?.let { e -> EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false)) } ?: EpicOptions(),
|
||||
cloud = o.optBoolean("cloud", true),
|
||||
achievements = o.optBoolean("achievements", true),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
@@ -44,7 +45,7 @@ object StoreInstalls {
|
||||
onStep?.invoke(0, STEPS)
|
||||
// The user's launch choices survive an update or a repair.
|
||||
val before = StoreGameSidecar.read(folder)
|
||||
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud))
|
||||
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements))
|
||||
finished.write(folder)
|
||||
if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}")
|
||||
onStep?.invoke(1, STEPS)
|
||||
@@ -76,14 +77,26 @@ object StoreInstalls {
|
||||
|
||||
/**
|
||||
* Removes a store game: its folder with the sidecar, then the shortcut (the listing without
|
||||
* it makes the writer drop the entry; the live client is asked too). Blocking.
|
||||
* it makes the writer drop the entry; the live client is asked too). Blocking. [removed] runs
|
||||
* once the files are gone, before the shortcut list is rebuilt (which reads every added game).
|
||||
*/
|
||||
fun uninstall(context: Context, game: InstalledStoreGame) {
|
||||
fun uninstall(context: Context, game: InstalledStoreGame, progress: (Float) -> Unit = {}, removed: () -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
val appId = AddedGames.scan(app).firstOrNull { it.folder.absolutePath == game.folder.absolutePath }?.appId
|
||||
// The shortcut's id as stored for the folder: scanning every added game for it first held
|
||||
// the bar at 0% for seconds before a file went.
|
||||
val appId = SessionPrefs.addedGameStoredAppId(app, game.folder.path)
|
||||
// File by file, deepest first, saying how far it has got: a big game takes a while to go.
|
||||
val files = game.folder.walkBottomUp().toList()
|
||||
val step = (files.size / 100).coerceAtLeast(1)
|
||||
files.forEachIndexed { i, f ->
|
||||
f.delete()
|
||||
if (i % step == 0) progress((i + 1).toFloat() / files.size)
|
||||
}
|
||||
deleteTree(game.folder)
|
||||
progress(1f)
|
||||
// The scratch cache an install on a card may have left in the app's cache.
|
||||
deleteTree(StoreInstallRoot.scratchDir(app, game.sidecar.store, game.sidecar.id))
|
||||
removed()
|
||||
register(app)
|
||||
if (appId != null) SteamLiveShortcuts.remove(app, appId)
|
||||
StoresState.logLine("uninstalled \"${game.sidecar.title}\"")
|
||||
|
||||
@@ -19,6 +19,9 @@ object StoreLaunchRequests {
|
||||
private val lock = Any()
|
||||
private var observer: FileObserver? = null
|
||||
|
||||
/** The store game this session last started; covers Steam's own Play button. */
|
||||
@Volatile var lastLaunched: Pair<Store, String>? = null
|
||||
|
||||
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "stores")
|
||||
|
||||
/** Before each session: nothing of the last one's requests; then watch for new ones. */
|
||||
@@ -28,6 +31,7 @@ object StoreLaunchRequests {
|
||||
observer?.stopWatching()
|
||||
val root = dir(app)
|
||||
root.deleteRecursively()
|
||||
lastLaunched = null
|
||||
val req = File(root, "req").apply { mkdirs() }
|
||||
File(root, "resp").mkdirs()
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -55,10 +59,13 @@ object StoreLaunchRequests {
|
||||
return // withdrawn already, or not complete yet (the rename brings it again)
|
||||
}
|
||||
val reply = when (request.optString("op")) {
|
||||
"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" -> {
|
||||
val id = request.optString("id")
|
||||
if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
|
||||
else {
|
||||
lastLaunched = Store.EPIC to id
|
||||
val code = StoreLaunch.epicCode(app, id)
|
||||
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
|
||||
}
|
||||
@@ -70,6 +77,7 @@ object StoreLaunchRequests {
|
||||
val id = request.optString("id")
|
||||
if (store == null || id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-game")
|
||||
else {
|
||||
lastLaunched = store to id
|
||||
// Marked before the game runs: whatever ends it, an upload follows (CloudSaves.uploadDirty).
|
||||
CloudSaves.markDirty(app, store, id)
|
||||
CloudSaves.downloadOrDefer(app, store, id).let { JSONObject().put("ok", it.ok).put("result", it.result).put("files", it.files).put("reason", it.reason) }
|
||||
|
||||
@@ -41,7 +41,7 @@ internal object StoreNet {
|
||||
}
|
||||
}
|
||||
|
||||
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000): String? {
|
||||
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000, headers: Map<String, String> = emptyMap()): String? {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
@@ -53,6 +53,7 @@ internal object StoreNet {
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
|
||||
headers.forEach { (k, v) -> setRequestProperty(k, v) }
|
||||
}
|
||||
OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body) }
|
||||
val code = conn.responseCode
|
||||
|
||||
@@ -99,6 +99,8 @@ object StoresState {
|
||||
/** Reads what is on disk for every store: sign-ins, installed games, the engine probe. Off the main thread. */
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
// The first refresh after the app starts brings back what was in the download queue.
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.restore(app)
|
||||
Thread({
|
||||
val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } }
|
||||
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
|
||||
@@ -172,7 +174,8 @@ object StoresState {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
backends[store]?.signOut(app)
|
||||
StoreAccounts.clear(app, store)
|
||||
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
|
||||
else StoreAccounts.clear(app, store)
|
||||
main.post {
|
||||
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
|
||||
logLine("${store.label}: signed out")
|
||||
@@ -217,15 +220,30 @@ object StoresState {
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
|
||||
val app = context.applicationContext
|
||||
val target = root ?: StoreInstallRoot.installRoot(app)
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
|
||||
backends[item.store]?.install(app, item, target) ?: logLine("${item.store.label}: this build cannot install yet")
|
||||
}
|
||||
|
||||
/** Removes the game's folder and its shortcut, then [onDone] on the main thread. */
|
||||
/** Games being removed, by key ("store:id"), and how far along (0..1): their page shows it. */
|
||||
val uninstalling = mutableStateMapOf<String, Float>()
|
||||
|
||||
/**
|
||||
* Removes the game's folder and its shortcut, then [onDone] on the main thread. The game reads
|
||||
* as removed the moment it is gone (its page shows Install, its card its size), not once the
|
||||
* whole library has been read again.
|
||||
*/
|
||||
fun uninstall(context: Context, game: InstalledStoreGame, onDone: () -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
val key = "${game.sidecar.store.id}:${game.sidecar.id}"
|
||||
uninstalling[key] = 0f
|
||||
Thread({
|
||||
runCatching { backends[game.sidecar.store]?.uninstall(app, game) }
|
||||
StoreInstalls.uninstall(app, game)
|
||||
StoreInstalls.uninstall(app, game, progress = { f -> main.post { if (key in uninstalling) uninstalling[key] = f } }, removed = {
|
||||
main.post {
|
||||
installed = installed.filterNot { it.folder.absolutePath == game.folder.absolutePath }
|
||||
uninstalling.remove(key)
|
||||
}
|
||||
})
|
||||
main.post(onDone)
|
||||
}, "stores-uninstall").start()
|
||||
}
|
||||
|
||||
@@ -137,6 +137,11 @@ object DownloadQueue {
|
||||
|
||||
private val lock = Any()
|
||||
private val items = LinkedHashMap<String, Item>()
|
||||
/** Keys a restart brings back paused: queued as paused, not started. */
|
||||
private val heldPaused = HashSet<String>()
|
||||
|
||||
/** [key]'s next [enqueue] comes in paused (a paused download restored after a restart). */
|
||||
fun holdPaused(key: String) = synchronized(lock) { heldPaused.add(key) }
|
||||
private var running = 0
|
||||
|
||||
fun parallel(context: Context): Int = SessionPrefs.gameStoresParallel(context)
|
||||
@@ -171,7 +176,8 @@ object DownloadQueue {
|
||||
synchronized(lock) {
|
||||
items[entry.key]?.let { if (it.entry.isActive) { Log.i(TAG, "${entry.key}: already queued"); return } }
|
||||
items.remove(entry.key)
|
||||
items[entry.key] = Item(entry.copy(state = DownloadState.QUEUED, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory)
|
||||
val state = if (heldPaused.remove(entry.key)) DownloadState.PAUSED else DownloadState.QUEUED
|
||||
items[entry.key] = Item(entry.copy(state = state, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory)
|
||||
renumber()
|
||||
}
|
||||
StoresState.logLine("${entry.store.label}: queued \"${entry.name}\"" + if (entry.bytesTotal > 0) " (${com.droiddeck.launcher.stores.formatBytes(entry.bytesTotal)})" else "")
|
||||
@@ -306,6 +312,8 @@ object DownloadQueue {
|
||||
}
|
||||
}
|
||||
lastPublishAt = now
|
||||
// A state change (not progress) is written down, so the queue outlives the app.
|
||||
if (!progressOnly) SavedQueue.save(items.values.map { it.entry })
|
||||
val list = items.values.map { it.entry }.sortedByDescending { it.startedAt }
|
||||
StoresState.post { StoresState.publishDownloads(list) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.droiddeck.launcher.stores.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* The download queue kept on disk, so what was queued, running or paused comes back after the app
|
||||
* closes or is killed: the queue itself lives in memory, and a queued game that never started has
|
||||
* nothing on disk to find. Each install records the game and where it goes ([remember]); every
|
||||
* change of state writes the active rows in queue order ([save]); the first refresh after the app
|
||||
* starts asks the stores for them again ([restore]) - running and queued ones carry on, paused ones
|
||||
* come back paused.
|
||||
*/
|
||||
object SavedQueue {
|
||||
private const val TAG = "StoreDownloads"
|
||||
private const val FILE = "store-queue.json"
|
||||
|
||||
private class Wanted(val item: CatalogItem, val root: File?)
|
||||
|
||||
private val wanted = HashMap<String, Wanted>()
|
||||
private val writer = Executors.newSingleThreadExecutor { r -> Thread(r, "store-queue-save") }
|
||||
@Volatile private var app: Context? = null
|
||||
private var restored = false
|
||||
|
||||
private fun key(item: CatalogItem) = "${item.store.id}:${item.id}"
|
||||
|
||||
/** An install is asked for: what to ask the store for again after a restart. */
|
||||
@Synchronized
|
||||
fun remember(context: Context, item: CatalogItem, root: File?) {
|
||||
app = context.applicationContext
|
||||
wanted[key(item)] = Wanted(item, root)
|
||||
}
|
||||
|
||||
/** The active rows, in queue order, as they are now. Any thread; written off it. */
|
||||
fun save(entries: List<DownloadEntry>) {
|
||||
val context = app ?: return
|
||||
val rows = synchronized(this) {
|
||||
entries.filter { it.isActive }.mapNotNull { e -> wanted[e.key]?.let { e to it } }
|
||||
}
|
||||
val out = JSONArray()
|
||||
for ((e, w) in rows) out.put(JSONObject().apply {
|
||||
put("paused", e.state == DownloadState.PAUSED)
|
||||
put("root", w.root?.absolutePath ?: "")
|
||||
put("item", itemJson(w.item))
|
||||
})
|
||||
writer.execute {
|
||||
runCatching {
|
||||
val f = File(context.filesDir, FILE)
|
||||
val tmp = File(context.filesDir, "$FILE.tmp")
|
||||
tmp.writeText(out.toString())
|
||||
tmp.renameTo(f)
|
||||
}.onFailure { Log.w(TAG, "could not save the queue", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Once per process: queues again what was in the queue when the app last closed. */
|
||||
fun restore(context: Context) {
|
||||
synchronized(this) { if (restored) return; restored = true }
|
||||
val app = context.applicationContext
|
||||
// Read before anything may write it: an install started at once must not save over it.
|
||||
val rows = runCatching { JSONArray(File(app.filesDir, FILE).readText()) }.getOrNull()
|
||||
this.app = app
|
||||
rows ?: return
|
||||
Thread({
|
||||
val back = (0 until rows.length()).mapNotNull { i ->
|
||||
runCatching {
|
||||
val o = rows.getJSONObject(i)
|
||||
Triple(itemOf(o.getJSONObject("item")), o.optString("root").takeIf { it.isNotEmpty() }?.let(::File), o.optBoolean("paused"))
|
||||
}.getOrNull()
|
||||
}
|
||||
if (back.isEmpty()) return@Thread
|
||||
StoresState.post {
|
||||
for ((item, root, paused) in back) {
|
||||
if (DownloadQueue.entry(key(item)) != null) continue
|
||||
if (paused) DownloadQueue.holdPaused(key(item))
|
||||
StoresState.install(app, item, root)
|
||||
}
|
||||
}
|
||||
}, "store-queue-restore").start()
|
||||
}
|
||||
|
||||
private fun itemJson(i: CatalogItem) = JSONObject().apply {
|
||||
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("extra", JSONObject(i.extra))
|
||||
}
|
||||
|
||||
private fun itemOf(o: JSONObject): CatalogItem {
|
||||
val extra = o.optJSONObject("extra")
|
||||
return CatalogItem(
|
||||
store = Store.byId(o.getString("store")) ?: error("unknown store"),
|
||||
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"),
|
||||
extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.droiddeck.launcher.stores.epic
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Epic's GraphQL: the sandbox's achievements, then the player's records in it. */
|
||||
object EpicAchievements : StoreAchievements(Store.EPIC.id, Store.EPIC.shortLabel) {
|
||||
private const val GRAPHQL = "https://launcher.store.epicgames.com/graphql"
|
||||
private const val USER_AGENT = "EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live"
|
||||
|
||||
override fun request(context: Context, game: Game): Result {
|
||||
val namespace = game.extra["namespace"].orEmpty()
|
||||
if (namespace.isEmpty()) return Result.Failed(Reason.NO_SERVICE)
|
||||
val token = EpicCredentialStore.getValidAccessToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
val defs = query(token, GAME_ACHIEVEMENTS, JSONObject().put("sandboxId", namespace).put("locale", locale()))
|
||||
?: return Result.Failed(Reason.UNREACHABLE)
|
||||
val list = defs.optJSONObject("data")?.optJSONObject("Achievement")?.optJSONObject("productAchievementsRecordBySandbox")
|
||||
?.optJSONArray("achievements") ?: return Result.Found(emptyList())
|
||||
val unlocked = if (account.isEmpty()) emptyMap() else unlockTimes(token, account, namespace)
|
||||
val out = ArrayList<Achievement>()
|
||||
for (i in 0 until list.length()) {
|
||||
val a = list.optJSONObject(i)?.optJSONObject("achievement") ?: continue
|
||||
val id = a.optString("name")
|
||||
if (id.isEmpty()) continue
|
||||
val at = unlocked[id]
|
||||
val done = at != null
|
||||
out += Achievement(
|
||||
id = id,
|
||||
name = (if (done) a.optString("unlockedDisplayName") else a.optString("lockedDisplayName"))
|
||||
.ifEmpty { a.optString("unlockedDisplayName") }.ifEmpty { id },
|
||||
description = (if (done) a.optString("unlockedDescription") else a.optString("lockedDescription"))
|
||||
.ifEmpty { a.optString("unlockedDescription") },
|
||||
hidden = a.optBoolean("hidden"),
|
||||
unlocked = done,
|
||||
unlockedAt = at ?: 0,
|
||||
icon = a.optString("unlockedIconLink").ifEmpty { null },
|
||||
iconLocked = a.optString("lockedIconLink").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
return Result.Found(out)
|
||||
}
|
||||
|
||||
/** Achievement name to unlock time, for the ones the player has. */
|
||||
private fun unlockTimes(token: String, account: String, namespace: String): Map<String, Long> {
|
||||
val out = HashMap<String, Long>()
|
||||
val records = query(token, PLAYER_ACHIEVEMENTS, JSONObject().put("epicAccountId", account).put("sandboxId", namespace))
|
||||
?.optJSONObject("data")?.optJSONObject("PlayerAchievement")?.optJSONObject("playerAchievementGameRecordsBySandbox")
|
||||
?.optJSONArray("records") ?: return out
|
||||
for (r in 0 until records.length()) {
|
||||
val player = records.optJSONObject(r)?.optJSONArray("playerAchievements") ?: continue
|
||||
for (i in 0 until player.length()) {
|
||||
val pa = player.optJSONObject(i)?.optJSONObject("playerAchievement") ?: continue
|
||||
if (pa.optBoolean("unlocked")) out[pa.optString("achievementName")] = epochSeconds(pa.optString("unlockDate"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun query(token: String, query: String, variables: JSONObject): JSONObject? =
|
||||
StoreNet.postJson(GRAPHQL, JSONObject().put("query", query).put("variables", variables).toString(), bearer = token, userAgent = USER_AGENT)
|
||||
?.let(::JSONObject)
|
||||
|
||||
private val GAME_ACHIEVEMENTS = """
|
||||
query Achievement(${'$'}sandboxId: String!, ${'$'}locale: String!) {
|
||||
Achievement {
|
||||
productAchievementsRecordBySandbox(sandboxId: ${'$'}sandboxId, locale: ${'$'}locale) {
|
||||
achievements {
|
||||
achievement {
|
||||
name hidden unlockedDisplayName lockedDisplayName unlockedDescription lockedDescription
|
||||
unlockedIconLink lockedIconLink XP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val PLAYER_ACHIEVEMENTS = """
|
||||
query PlayerAchievement(${'$'}epicAccountId: String!, ${'$'}sandboxId: String!) {
|
||||
PlayerAchievement {
|
||||
playerAchievementGameRecordsBySandbox(epicAccountId: ${'$'}epicAccountId, sandboxId: ${'$'}sandboxId) {
|
||||
records { playerAchievements { playerAchievement { achievementName unlocked unlockDate XP } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -103,7 +103,11 @@ public final class EpicApiClient {
|
||||
if (attrs != null) {
|
||||
JSONObject offline = attrs.optJSONObject("CanRunOffline");
|
||||
if (offline != null) game.canRunOffline = !"false".equalsIgnoreCase(offline.optString("value", "true"));
|
||||
JSONObject publisher = attrs.optJSONObject("publisherName");
|
||||
if (publisher != null) game.publisher = publisher.optString("value", "").trim();
|
||||
}
|
||||
JSONObject seller = item.optJSONObject("seller");
|
||||
if (game.publisher.isEmpty() && seller != null) game.publisher = seller.optString("name", "").trim();
|
||||
String rd = item.optString("viewableDate", "");
|
||||
if (rd.isEmpty()) rd = item.optString("effectiveDate", "");
|
||||
if (!rd.isEmpty()) game.releaseDate = rd;
|
||||
|
||||
@@ -80,7 +80,12 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
|
||||
val byCatalogItem = owned.associateBy { it.extra["catalogItemId"] ?: "" }
|
||||
fun mark(l: List<CatalogItem>) = l.map { i ->
|
||||
val match = i.extra["items"]?.split(',')?.firstNotNullOfOrNull { byCatalogItem[it] }
|
||||
if (match != null) match.copy(imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl) else i
|
||||
// The offer's art, and its facts where the library's catalog record has none.
|
||||
if (match != null) match.copy(
|
||||
imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl,
|
||||
developer = match.developer.ifBlank { i.developer }, publisher = match.publisher.ifBlank { i.publisher },
|
||||
releaseDate = match.releaseDate.ifBlank { i.releaseDate },
|
||||
) else i
|
||||
}
|
||||
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
|
||||
}
|
||||
|
||||
@@ -548,10 +548,10 @@ public final class EpicDownloadManager {
|
||||
try {
|
||||
JSONObject root = new JSONObject(new String(bytes, StandardCharsets.UTF_8));
|
||||
int manifestVersion = 0;
|
||||
try { manifestVersion = Integer.parseInt(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { manifestVersion = (int) blobToNum(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {}
|
||||
Manifest m = new Manifest();
|
||||
m.chunkDir = manifestVersion >= 15 ? "ChunksV4" : manifestVersion >= 6 ? "ChunksV3" : manifestVersion >= 3 ? "ChunksV2" : "ChunksV4";
|
||||
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
|
||||
m.launchExe = root.optString("LaunchExeString", root.optString("LaunchExe", "")).replace('\\', '/');
|
||||
m.launchCommand = root.optString("LaunchCommand", "");
|
||||
m.buildVersion = root.optString("BuildVersionString", "");
|
||||
m.prereqName = root.optString("PrereqName", "");
|
||||
@@ -568,10 +568,9 @@ public final class EpicDownloadManager {
|
||||
if (guidHex.length() < 32) continue;
|
||||
ChunkInfo c = new ChunkInfo();
|
||||
for (int k = 0; k < 4; k++) c.guid[k] = (int) Long.parseLong(guidHex.substring(k * 8, k * 8 + 8), 16);
|
||||
String hashHex = chunkHashList.getString(guidHex);
|
||||
if (hashHex != null && hashHex.length() >= 16) { try { c.hash = Long.parseUnsignedLong(hashHex.substring(0, 16), 16); } catch (Exception ignored) {} }
|
||||
if (dataGroupList != null) { try { c.groupNum = Integer.parseInt(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
|
||||
if (chunkFilesizeList != null) { try { c.fileSize = Long.parseLong(chunkFilesizeList.optString(guidHex, "0"), 16); } catch (NumberFormatException ignored) {} }
|
||||
try { c.hash = blobToNum(chunkHashList.getString(guidHex)); } catch (NumberFormatException ignored) {}
|
||||
if (dataGroupList != null) { try { c.groupNum = (int) blobToNum(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
|
||||
if (chunkFilesizeList != null) { try { c.fileSize = blobToNum(chunkFilesizeList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
|
||||
chunkMap.put(guidHex, c);
|
||||
}
|
||||
JSONArray fileList = root.optJSONArray("FileManifestList");
|
||||
@@ -589,8 +588,8 @@ public final class EpicDownloadManager {
|
||||
ChunkPart part = new ChunkPart();
|
||||
String g = po.optString("Guid", "");
|
||||
if (g.length() >= 32) for (int k = 0; k < 4; k++) part.guid[k] = (int) Long.parseLong(g.substring(k * 8, k * 8 + 8), 16);
|
||||
try { part.offset = Integer.parseInt(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { part.size = Integer.parseInt(po.optString("Size", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { part.offset = (int) blobToNum(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { part.size = (int) blobToNum(po.optString("Size", "0")); } catch (NumberFormatException ignored) {}
|
||||
fi.parts.add(part);
|
||||
}
|
||||
files.add(fi);
|
||||
@@ -604,6 +603,24 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON manifest writes every number as a "blob": each byte as three decimal digits, least
|
||||
* significant first ("013000000000" = 13), as Legendary's blob_to_num reads it. Bits past 64
|
||||
* are dropped; a group that is not digits throws.
|
||||
*/
|
||||
static long blobToNum(String blob) {
|
||||
long num = 0;
|
||||
for (int i = 0, shift = 0; i < blob.length(); i += 3, shift += 8) {
|
||||
String group = blob.substring(i, Math.min(i + 3, blob.length()));
|
||||
for (int k = 0; k < group.length(); k++) {
|
||||
char ch = group.charAt(k);
|
||||
if (ch < '0' || ch > '9') throw new NumberFormatException("not a blob: " + blob);
|
||||
}
|
||||
if (shift < 64) num += Long.parseLong(group) << shift;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
public static String readFString(ByteBuffer buf) {
|
||||
int length = buf.getInt();
|
||||
if (length == 0) return "";
|
||||
|
||||
@@ -22,4 +22,5 @@ public class EpicGame {
|
||||
public boolean isDLC = false;
|
||||
public String baseGameCatalogItemId = "";
|
||||
public String releaseDate = "";
|
||||
public String publisher = "";
|
||||
}
|
||||
@@ -44,6 +44,8 @@ object EpicLibrary {
|
||||
g.developer = j.optString("developer", ""); g.description = j.optString("description", ""); g.version = j.optString("version", "")
|
||||
g.installSize = j.optLong("installSize", 0L).let { if (it > 1_099_511_627_776L) 0L else it }
|
||||
g.canRunOffline = j.optBoolean("canRunOffline", true)
|
||||
g.releaseDate = j.optString("releaseDate", "")
|
||||
g.publisher = j.optString("publisher", "")
|
||||
if (g.appName.isNotEmpty()) list.add(g)
|
||||
}
|
||||
list.distinctBy { it.appName }.sortedBy { it.title.lowercase() }
|
||||
@@ -74,7 +76,6 @@ object EpicLibrary {
|
||||
var done = 0
|
||||
for (game in raw) {
|
||||
EpicApiClient.enrichFromCatalog(token, game)
|
||||
if (game.releaseDate.isNotEmpty()) p.edit().putString("release_${game.appName}", game.releaseDate).apply()
|
||||
done++
|
||||
if (done % 5 == 0) onStatus("Loading game details… ($done/$total)")
|
||||
}
|
||||
@@ -102,7 +103,8 @@ object EpicLibrary {
|
||||
val arr = JSONArray()
|
||||
for (g in games) arr.put(JSONObject().put("appName", g.appName).put("namespace", g.namespace).put("catalogItemId", g.catalogItemId)
|
||||
.put("title", g.title).put("artCover", g.artCover).put("artSquare", g.artSquare).put("developer", g.developer)
|
||||
.put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline))
|
||||
.put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline)
|
||||
.put("releaseDate", g.releaseDate).put("publisher", g.publisher))
|
||||
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,9 @@ object EpicLibrary {
|
||||
store = Store.EPIC, id = g.appName, title = g.title.ifBlank { g.appName },
|
||||
imageUrl = g.artSquare.ifBlank { null } ?: g.artCover.ifBlank { null },
|
||||
tallImageUrl = g.artCover.ifBlank { null } ?: g.artSquare.ifBlank { null },
|
||||
developer = g.developer, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true,
|
||||
developer = g.developer, publisher = g.publisher, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true,
|
||||
// A cache from before the release date was kept still has it under the older per-game key.
|
||||
releaseDate = g.releaseDate.ifEmpty { EpicPrefs.get(context).getString("release_${g.appName}", "").orEmpty() },
|
||||
sizeBytes = EpicPrefs.get(context).getLong("size_${g.appName}", g.installSize),
|
||||
storeUrl = "https://store.epicgames.com/en-US/browse?q=${java.net.URLEncoder.encode(g.title, "UTF-8")}",
|
||||
extra = mapOf("namespace" to g.namespace, "catalogItemId" to g.catalogItemId, "version" to (g.version ?: "")),
|
||||
|
||||
@@ -32,7 +32,7 @@ object EpicStoreCatalog {
|
||||
|
||||
private const val ELEMENT_FIELDS =
|
||||
"title id namespace description effectiveDate releaseDate offerType productSlug urlSlug " +
|
||||
"items{id namespace} keyImages{type url} seller{name} tags{name} " +
|
||||
"items{id namespace} keyImages{type url} seller{name} tags{name} customAttributes{key value} " +
|
||||
"catalogNs{mappings(pageType:\"productHome\"){pageSlug pageType}} " +
|
||||
"price(country:\$country){totalPrice{discountPrice originalPrice discount currencyCode " +
|
||||
"fmtPrice(locale:\$locale){originalPrice discountPrice}}}"
|
||||
@@ -116,13 +116,21 @@ object EpicStoreCatalog {
|
||||
return CatalogItem(
|
||||
store = Store.EPIC, id = id, title = title, imageUrl = wide ?: thumb, tallImageUrl = tall ?: thumb, tags = tagLine,
|
||||
isFree = isFree, hasPrice = hasPrice, finalPrice = finalPrice, originalPrice = if (discountPct > 0) fmtOriginal else "", discountPercent = discountPct,
|
||||
storeUrl = storeUrl, developer = e.optJSONObject("seller")?.optString("name", "").orEmpty(),
|
||||
storeUrl = storeUrl, developer = attribute(e, "developerName"),
|
||||
publisher = attribute(e, "publisherName").ifEmpty { e.optJSONObject("seller")?.optString("name", "").orEmpty() },
|
||||
releaseDate = e.optString("releaseDate", e.optString("effectiveDate", "")), description = com.droiddeck.launcher.stores.cleanStoreText(e.optString("description", "")),
|
||||
extra = buildMap { put("namespace", ns); if (itemIds.isNotEmpty()) put("items", itemIds.joinToString(",")); if (slug.isNotBlank()) put("slug", slug) },
|
||||
mature = com.droiddeck.launcher.stores.matureByTags(allTags),
|
||||
)
|
||||
}
|
||||
|
||||
// An offer's custom attribute (developerName, publisherName), or "".
|
||||
private fun attribute(e: JSONObject, key: String): String {
|
||||
val attrs = e.optJSONArray("customAttributes") ?: return ""
|
||||
return (0 until attrs.length()).mapNotNull { attrs.optJSONObject(it) }.firstOrNull { it.optString("key") == key }
|
||||
?.optString("value", "")?.trim().orEmpty()
|
||||
}
|
||||
|
||||
private fun pageSlugOf(e: JSONObject): String {
|
||||
e.optJSONObject("catalogNs")?.optJSONArray("mappings")?.let { m ->
|
||||
for (i in 0 until m.length()) { val map = m.optJSONObject(i) ?: continue; if (map.optString("pageType") == "productHome") { val s = map.optString("pageSlug", ""); if (s.isNotBlank()) return s } }
|
||||
@@ -193,7 +201,7 @@ object EpicStoreCatalog {
|
||||
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
|
||||
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("final", i.finalPrice); put("orig", i.originalPrice); put("disc", i.discountPercent)
|
||||
put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
|
||||
put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
|
||||
if (i.mature) put("mature", true)
|
||||
}
|
||||
|
||||
@@ -203,7 +211,7 @@ object EpicStoreCatalog {
|
||||
return CatalogItem(
|
||||
store = Store.EPIC, 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("final"), originalPrice = o.optString("orig"),
|
||||
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
|
||||
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
|
||||
mature = o.optBoolean("mature"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.droiddeck.launcher.stores.gog
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreLog
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.InflaterInputStream
|
||||
|
||||
/** gameplay.gog.com, with a token scoped to the game's own client. */
|
||||
object GogAchievements : StoreAchievements(Store.GOG.id, Store.GOG.shortLabel) {
|
||||
private const val TAG = "GogAchievements"
|
||||
|
||||
override fun request(context: Context, game: Game): Result {
|
||||
val galaxy = GogAuth.validToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
|
||||
val (clientId, clientSecret) = client(context, game.id, galaxy) ?: return Result.Failed(Reason.NO_SERVICE)
|
||||
val scoped = GogAuth.gameToken(context, clientId, clientSecret) ?: return Result.Failed(Reason.UNREACHABLE)
|
||||
val body = StoreNet.get(
|
||||
"https://gameplay.gog.com/clients/$clientId/users/${scoped.userId}/achievements",
|
||||
bearer = scoped.accessToken, userAgent = GogAuth.GALAXY_UA, headers = mapOf("X-Gog-Lc" to locale()),
|
||||
) ?: return Result.Failed(Reason.UNREACHABLE)
|
||||
val items = body.takeIf { it.isNotBlank() }?.let(::JSONObject)?.optJSONArray("items") ?: return Result.Found(emptyList())
|
||||
val out = ArrayList<Achievement>()
|
||||
for (i in 0 until items.length()) {
|
||||
val item = items.optJSONObject(i) ?: continue
|
||||
val id = item.optString("achievement_key").ifEmpty { item.optString("achievement_id") }
|
||||
if (id.isEmpty()) continue
|
||||
val date = item.optString("date_unlocked").takeIf { it.isNotBlank() && it != "null" }
|
||||
out += Achievement(
|
||||
id = id,
|
||||
name = item.optString("name").ifEmpty { id },
|
||||
description = item.optString("description"),
|
||||
hidden = !item.optBoolean("visible", true),
|
||||
unlocked = date != null,
|
||||
unlockedAt = date?.let(::epochSeconds) ?: 0,
|
||||
icon = item.optString("image_url_unlocked").ifEmpty { item.optString("image_url_locked") }.ifEmpty { null },
|
||||
iconLocked = item.optString("image_url_locked").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
return Result.Found(out)
|
||||
}
|
||||
|
||||
/** The game's client id and secret: saved at install, else from the newest build's manifest. */
|
||||
private fun client(context: Context, productId: String, token: String): Pair<String, String>? {
|
||||
val prefs = GogPrefs.get(context)
|
||||
val savedId = prefs.getString("client_id_$productId", null)?.ifEmpty { null }
|
||||
val savedSecret = prefs.getString("client_secret_$productId", null)?.ifEmpty { null }
|
||||
if (savedId != null && savedSecret != null) return savedId to savedSecret
|
||||
val builds = StoreNet.get("https://content-system.gog.com/products/$productId/os/windows/builds?generation=2", bearer = token)
|
||||
?.let(::JSONObject) ?: return null
|
||||
val link = builds.optJSONArray("items")?.optJSONObject(0)?.optString("link")?.ifEmpty { null } ?: return null
|
||||
val manifest = bytes(link, token)?.let { JSONObject(inflate(it)) } ?: return null
|
||||
val id = manifest.optString("clientId").ifEmpty { return null }
|
||||
val secret = manifest.optString("clientSecret").ifEmpty { return null }
|
||||
prefs.edit().putString("client_id_$productId", id).putString("client_secret_$productId", secret).apply()
|
||||
return id to secret
|
||||
}
|
||||
|
||||
private fun bytes(url: String, bearer: String): ByteArray? {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000; readTimeout = 20_000
|
||||
setRequestProperty("Authorization", "Bearer $bearer")
|
||||
}
|
||||
if (conn.responseCode !in 200..299) null else conn.inputStream.use { it.readBytes() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
|
||||
null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Gen2 manifests are zlib-compressed; plain JSON passes through. */
|
||||
private fun inflate(data: ByteArray): String =
|
||||
if (data.isNotEmpty() && data[0] == '{'.code.toByte()) String(data, Charsets.UTF_8)
|
||||
else InflaterInputStream(data.inputStream()).use { String(it.readBytes(), Charsets.UTF_8) }
|
||||
}
|
||||
@@ -33,7 +33,12 @@ object GogAuth {
|
||||
fun isSignedIn(context: Context): Boolean = StoreAccounts.read(context, Store.GOG)?.optString("access_token", "")?.isNotEmpty() == true
|
||||
|
||||
/** Saves a fresh sign-in and looks the account's name up for the chip. Blocking. */
|
||||
fun saveLogin(context: Context, accessToken: String, refreshToken: String?, userId: String?) {
|
||||
fun saveLogin(context: Context, accessToken: String, refreshToken: String?, userId: String?) = GogComet.replaceAccount {
|
||||
saveAccount(context, accessToken, refreshToken, userId)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun saveAccount(context: Context, accessToken: String, refreshToken: String?, userId: String?) {
|
||||
var username = ""
|
||||
runCatching {
|
||||
val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = accessToken, userAgent = GALAXY_UA)
|
||||
@@ -52,6 +57,7 @@ object GogAuth {
|
||||
|
||||
/** A usable access token, refreshed when the recorded login time says it has expired; null when signed out or the refresh failed. */
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
fun validToken(context: Context): String? {
|
||||
val json = StoreAccounts.read(context, Store.GOG) ?: return null
|
||||
val token = json.optString("access_token", "").ifEmpty { return null }
|
||||
@@ -63,6 +69,7 @@ object GogAuth {
|
||||
|
||||
/** Refreshes the access token; the new one, or null on any failure. Blocking. */
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
fun refresh(context: Context): String? {
|
||||
val json = StoreAccounts.read(context, Store.GOG) ?: return null
|
||||
val refreshToken = json.optString("refresh_token", "").ifEmpty { return null }
|
||||
@@ -87,6 +94,51 @@ object GogAuth {
|
||||
}
|
||||
}
|
||||
|
||||
/** A complete companion snapshot. Never exported through the guest request channel. */
|
||||
@Synchronized
|
||||
fun cometCredentials(context: Context): JSONObject? {
|
||||
val access = validToken(context) ?: return null
|
||||
val saved = StoreAccounts.read(context, Store.GOG) ?: return null
|
||||
val refresh = saved.optString("refresh_token").ifEmpty { return null }
|
||||
var user = saved.optString("user_id")
|
||||
var username = saved.optString("username")
|
||||
if (!user.matches(Regex("[0-9]+")) || username.isEmpty()) {
|
||||
val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = access, userAgent = GALAXY_UA, timeoutMs = 5_000) ?: return null
|
||||
val info = runCatching { JSONObject(body) }.getOrNull() ?: return null
|
||||
user = info.optString("galaxyUserId", info.optString("userId", user))
|
||||
username = info.optString("username", username)
|
||||
if (!user.matches(Regex("[0-9]+")) || username.isEmpty()) return null
|
||||
saved.put("user_id", user).put("username", username)
|
||||
StoreAccounts.write(context, Store.GOG, saved)
|
||||
}
|
||||
return JSONObject().put("access_token", access).put("refresh_token", refresh)
|
||||
.put("user_id", user).put("username", username)
|
||||
}
|
||||
|
||||
class GameToken(val accessToken: String, val userId: String)
|
||||
|
||||
/** The Galaxy refresh token exchanged for one scoped to a game's own client. Blocking. */
|
||||
fun gameToken(context: Context, clientId: String, clientSecret: String): GameToken? {
|
||||
val json = StoreAccounts.read(context, Store.GOG) ?: return null
|
||||
val refresh = json.optString("refresh_token", "").ifEmpty { return null }
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
|
||||
conn = (URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(clientSecret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}")
|
||||
.openConnection() as HttpURLConnection).apply { connectTimeout = 15_000; readTimeout = 15_000 }
|
||||
conn.setRequestProperty("User-Agent", "GOG Galaxy")
|
||||
if (conn.responseCode != 200) { Log.w(TAG, "game token: HTTP ${conn.responseCode}"); return null }
|
||||
val o = JSONObject(conn.inputStream.bufferedReader().readText())
|
||||
val access = o.optString("access_token").ifEmpty { return null }
|
||||
GameToken(access, o.optString("user_id").ifEmpty { json.optString("user_id") })
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "game token failed: ${e.javaClass.simpleName}")
|
||||
null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Hosts the GOG login page hands off to for a social sign-in; they refuse an embedded browser's identity. */
|
||||
fun isIdentityProviderHost(host: String?): Boolean {
|
||||
val h = host?.lowercase() ?: return false
|
||||
|
||||
@@ -71,7 +71,7 @@ object GogBackend : StoreBackend {
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl)
|
||||
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl, item.releaseDate, item.publisher)
|
||||
val folder = StoreInstallRoot.folderFor(app, Store.GOG, game.gameId, game.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) { InstallJob(app, game, item, folder) }
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.droiddeck.launcher.stores.gog
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.AchievementNotifications
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Owns one Galaxy companion; only its structured, secret-free events reach Android logs. */
|
||||
object GogComet {
|
||||
private const val TAG = "GogComet"
|
||||
private const val BIN = "/usr/local/lib/droiddeck-comet/comet"
|
||||
private val lock = Any()
|
||||
@Volatile private var process: Process? = null
|
||||
private var pid = -1
|
||||
private var started: String? = null
|
||||
private var account = ""
|
||||
private var session: String? = null
|
||||
private var accepting = false
|
||||
private var generation: String? = null
|
||||
@Volatile private var gamesByClient: Map<String, StoreGameSidecar> = emptyMap()
|
||||
var status by mutableStateOf("off")
|
||||
private set
|
||||
|
||||
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", false)
|
||||
|
||||
fun setEnabled(context: Context, on: Boolean) {
|
||||
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
|
||||
|
||||
}
|
||||
|
||||
fun begin() = synchronized(lock) {
|
||||
stopLocked()
|
||||
accepting = true
|
||||
generation = SessionState.sessionId
|
||||
AchievementNotifications.begin(generation)
|
||||
}
|
||||
|
||||
/** Account replacement and launch preparation use the same lock, preventing stale restarts. */
|
||||
fun replaceAccount(change: () -> Unit) = synchronized(lock) {
|
||||
stopLocked()
|
||||
AchievementNotifications.begin(generation)
|
||||
change()
|
||||
}
|
||||
|
||||
fun stop() = synchronized(lock) {
|
||||
stopLocked()
|
||||
}
|
||||
|
||||
fun end(expected: String?) = synchronized(lock) {
|
||||
if (generation == expected) {
|
||||
accepting = false
|
||||
stopLocked()
|
||||
AchievementNotifications.end(expected)
|
||||
generation = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun report(value: String) {
|
||||
StoresState.post { status = value }
|
||||
Log.i(TAG, value)
|
||||
SessionEvents.record("gog.achievements", mapOf("status" to value))
|
||||
}
|
||||
|
||||
private fun publishPolicy(context: Context): File {
|
||||
val dir = File(context.filesDir, "gog-achievements").apply { mkdirs() }
|
||||
val games = if (!enabled(context)) emptyMap() else StoreInstallRoot.gameFolders(context).mapNotNull { folder ->
|
||||
val game = StoreGameSidecar.read(folder) ?: return@mapNotNull null
|
||||
if (game.store != Store.GOG || !game.achievements) return@mapNotNull null
|
||||
val client = GogPrefs.get(context).getString("client_id_${game.id}", null)
|
||||
?: runCatching { JSONObject(File(folder, "goggame-${game.id}.info").readText()).optString("clientId") }.getOrNull()
|
||||
client?.takeIf { it.matches(Regex("[0-9]+")) }?.let { it to game }
|
||||
}.toMap()
|
||||
gamesByClient = games
|
||||
val file = File(dir, "clients.json")
|
||||
val tmp = File(dir, "clients.json.tmp")
|
||||
tmp.writeText(org.json.JSONArray(games.keys.toList()).toString())
|
||||
check(tmp.renameTo(file))
|
||||
return file
|
||||
}
|
||||
|
||||
/** No arbitrary paths or credentials from the guest are accepted. */
|
||||
fun prepare(context: Context, id: String): JSONObject = synchronized(lock) {
|
||||
fun reply(ready: Boolean, reason: String): JSONObject {
|
||||
report(reason)
|
||||
return JSONObject().put("ok", ready).put("ready", ready).put("reason", reason)
|
||||
}
|
||||
if (!accepting || !SessionState.running || generation != SessionState.sessionId) return reply(false, "no-session")
|
||||
val folder = StoreInstallRoot.gameFolders(context).firstOrNull { f -> StoreGameSidecar.read(f)?.let { it.store == Store.GOG && it.id == id } == true }
|
||||
val sidecar = folder?.let { StoreGameSidecar.read(it) }
|
||||
if (sidecar?.store != Store.GOG || sidecar.id != id) return reply(false, "no-game")
|
||||
val policy = publishPolicy(context)
|
||||
if (!enabled(context) || !sidecar.achievements) return reply(false, "off")
|
||||
val credentials = GogAuth.cometCredentials(context) ?: return reply(false,
|
||||
if (StoreAccounts.isUnavailable(Store.GOG)) "credentials-unavailable"
|
||||
else if (StoreAccounts.read(context, Store.GOG)?.optString("refresh_token").isNullOrEmpty()) "sign-in-needed" else "credentials-unavailable")
|
||||
if (!SessionState.running || generation != SessionState.sessionId) return reply(false, "no-session")
|
||||
val user = credentials.getString("user_id")
|
||||
if (process?.isAlive == true && account == user && session == SessionState.sessionId && identity(pid) == started) {
|
||||
return JSONObject().put("ok", true).put("ready", true).put("reason", "ready")
|
||||
}
|
||||
stopLocked()
|
||||
if (!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
|
||||
val data = File(context.filesDir, "gog-achievements").apply { mkdirs() }
|
||||
val ready = CountDownLatch(1)
|
||||
val command = LinuxRuntime.command(context, null, null, Environment.getExternalStorageDirectory(),
|
||||
listOf("/usr/bin/env", "HOME=/root", "XDG_DATA_HOME=${data.path}/data", "XDG_CONFIG_HOME=${data.path}/config", "COMET_LOG=off", "COMET_ALLOWED_CLIENTS=${policy.path}", BIN, "--credentials-stdin"))
|
||||
val pb = ProcessBuilder(command).redirectErrorStream(true)
|
||||
pb.environment().apply {
|
||||
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
|
||||
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
|
||||
put("PROOT_TMP_DIR", context.cacheDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
try {
|
||||
val child = pb.start()
|
||||
process = child; account = user; session = SessionState.sessionId
|
||||
Thread({
|
||||
runCatching { child.inputStream.bufferedReader().useLines { lines -> lines.forEach { line ->
|
||||
// Raw upstream errors/payloads can contain tokens. Never forward them.
|
||||
when {
|
||||
line.startsWith("DROIDDECK_COMET_READY:") -> {
|
||||
val childPid = line.substringAfter(':').toIntOrNull() ?: -1
|
||||
if (childPid > 1 && process === child) {
|
||||
pid = childPid; started = identity(childPid); ready.countDown()
|
||||
}
|
||||
}
|
||||
line.startsWith("DROIDDECK_COMET_UNLOCKED:") -> if (process === child) {
|
||||
val event = runCatching { JSONObject(line.substringAfter(':')) }.getOrNull()
|
||||
val game = event?.optString("client_id")?.let { gamesByClient[it] }
|
||||
val unlock = event?.let { AchievementNotifications.parse(it, game?.title.orEmpty()) }
|
||||
val owner = session
|
||||
if (game != null && unlock != null && owner != null && owner == SessionState.sessionId && SessionState.running) {
|
||||
report("unlocked-locally")
|
||||
AchievementNotifications.unlock(owner, unlock)
|
||||
SessionEvents.record("achievement.unlocked", mapOf("store" to "gog", "gameId" to game.id, "achievementId" to unlock.id))
|
||||
}
|
||||
}
|
||||
line == "DROIDDECK_COMET_PENDING" -> if (process === child) report("pending-upload")
|
||||
line.startsWith("DROIDDECK_COMET_UPLOADED:") -> if (process === child && line.substringAfter(':').toIntOrNull()?.let { it > 0 } == true) { report("uploaded"); StoresState.post { StoreAchievements.invalidate() } }
|
||||
}
|
||||
} } }
|
||||
if (process === child) report("service-unavailable")
|
||||
ready.countDown()
|
||||
}, "gog-comet-output").start()
|
||||
child.outputStream.bufferedWriter().use { it.write(credentials.toString()) }
|
||||
if (!ready.await(5, TimeUnit.SECONDS) || pid <= 1 || started == null || !child.isAlive) {
|
||||
stopLocked(); return reply(false, "service-unavailable")
|
||||
}
|
||||
reply(true, "ready")
|
||||
} catch (e: Exception) {
|
||||
stopLocked()
|
||||
reply(false, "service-unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
private fun identity(pid: Int): String? = if (pid <= 1) null else runCatching {
|
||||
File("/proc/$pid/stat").readText().substringAfterLast(") ").split(' ')[19]
|
||||
}.getOrNull()
|
||||
|
||||
private fun stopLocked() {
|
||||
val child = process ?: return
|
||||
// Signal the announced daemon, not proot; start time prevents signalling a reused PID.
|
||||
if (started != null && identity(pid) == started) android.os.Process.sendSignal(pid, 2)
|
||||
if (!child.waitFor(5, TimeUnit.SECONDS)) {
|
||||
if (started != null && identity(pid) == started) android.os.Process.sendSignal(pid, 15)
|
||||
child.destroy()
|
||||
}
|
||||
process = null; pid = -1; started = null; account = ""; session = null
|
||||
report("stopped")
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,12 @@ public class GogGame {
|
||||
public final int generation;
|
||||
/** gamesdb.gog.com 2:3 box art, or null when the lookup found none. */
|
||||
public final String verticalCover;
|
||||
/** ISO date-time of the worldwide release, or "" when GOG gave none. */
|
||||
public final String releaseDate;
|
||||
public final String publisher;
|
||||
|
||||
public GogGame(String gameId, String title, String imageUrl, String description, String developer,
|
||||
String category, int generation, String verticalCover) {
|
||||
String category, int generation, String verticalCover, String releaseDate, String publisher) {
|
||||
this.gameId = gameId;
|
||||
this.title = title;
|
||||
this.imageUrl = imageUrl;
|
||||
@@ -23,5 +26,7 @@ public class GogGame {
|
||||
this.category = category;
|
||||
this.generation = generation;
|
||||
this.verticalCover = verticalCover;
|
||||
this.releaseDate = releaseDate;
|
||||
this.publisher = publisher;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,8 @@ object GogLibrary {
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
GogGame(o.optString("gameId", ""), o.optString("title", ""), o.optString("imageUrl", ""), cleanStoreText(o.optString("description", "")),
|
||||
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null })
|
||||
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null },
|
||||
o.optString("releaseDate", ""), o.optString("publisher", ""))
|
||||
}.filter { it.gameId.isNotEmpty() }.sortedBy { it.title.lowercase() }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -148,7 +149,15 @@ object GogLibrary {
|
||||
val desc = cleanStoreText(prod.optJSONObject("description")?.optString("lead", ""))
|
||||
.ifEmpty { cleanStoreText(v2?.optString("overview", "")) }
|
||||
.ifEmpty { cleanStoreText(v2?.optString("description", "")) }
|
||||
val developer = prod.optJSONObject("developers")?.optString("name", "") ?: prod.optString("developer", "")
|
||||
// The products endpoint names no developer or publisher; the v2 record does, with the worldwide release date.
|
||||
val embedded = v2?.optJSONObject("_embedded")
|
||||
fun names(key: String) = embedded?.optJSONArray(key)
|
||||
?.let { arr -> (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name")?.takeIf(String::isNotBlank) } }
|
||||
?.joinToString(", ").orEmpty()
|
||||
val developer = names("developers")
|
||||
val publisher = embedded?.optJSONObject("publisher")?.optString("name")?.takeIf(String::isNotBlank) ?: names("publishers")
|
||||
val releaseDate = listOf(embedded?.optJSONObject("product")?.optString("globalReleaseDate"), prod.optString("release_date"))
|
||||
.firstOrNull { !it.isNullOrBlank() && it != "null" }.orEmpty()
|
||||
val genres = prod.optJSONArray("genres")
|
||||
val category = if (genres != null && genres.length() > 0) genres.optJSONObject(0)?.optString("name", "") ?: "" else ""
|
||||
|
||||
@@ -164,9 +173,7 @@ object GogLibrary {
|
||||
if (maxGen > 0) generation = maxGen
|
||||
}
|
||||
}
|
||||
val ed = prefs.edit().putInt("gen_$id", generation)
|
||||
prod.optString("release_date", "").takeIf { it.isNotEmpty() }?.let { ed.putString("release_$id", it) }
|
||||
ed.apply()
|
||||
prefs.edit().putInt("gen_$id", generation).apply()
|
||||
if (prefs.getLong("size_$id", -1L) <= 0) {
|
||||
val size = GogDownloadManager.fetchInstallSizeBytes(id, token)
|
||||
if (size > 0) prefs.edit().putLong("size_$id", size).apply()
|
||||
@@ -182,7 +189,7 @@ object GogLibrary {
|
||||
verticalCover = fetchVerticalCover(id).ifEmpty { StoreNet.sgdbPoster(title) }
|
||||
}
|
||||
if (!verticalCover.isNullOrEmpty()) prefs.edit().putString("vcover_$id", verticalCover).apply()
|
||||
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null })
|
||||
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null }, releaseDate, publisher)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
@@ -203,7 +210,7 @@ object GogLibrary {
|
||||
val arr = JSONArray()
|
||||
for (g in games) {
|
||||
arr.put(JSONObject().put("gameId", g.gameId).put("title", g.title).put("imageUrl", g.imageUrl).put("description", g.description)
|
||||
.put("developer", g.developer).put("category", g.category).put("generation", g.generation)
|
||||
.put("developer", g.developer).put("category", g.category).put("generation", g.generation).put("releaseDate", g.releaseDate).put("publisher", g.publisher)
|
||||
.apply { if (!g.verticalCover.isNullOrEmpty()) put("verticalCover", g.verticalCover) })
|
||||
}
|
||||
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
|
||||
@@ -215,7 +222,9 @@ object GogLibrary {
|
||||
store = Store.GOG, id = g.gameId, title = g.title,
|
||||
imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null },
|
||||
tallImageUrl = GogStoreCatalog.absolutize(g.verticalCover ?: g.imageUrl).ifBlank { null },
|
||||
tags = g.category, developer = g.developer, description = g.description, owned = true,
|
||||
tags = g.category, developer = g.developer, publisher = g.publisher, description = g.description, owned = true,
|
||||
// A cache from before the release date was kept still has it under the older per-game key.
|
||||
releaseDate = g.releaseDate.ifEmpty { GogPrefs.get(context).getString("release_${g.gameId}", "").orEmpty() },
|
||||
sizeBytes = sizeOf(context, g.gameId),
|
||||
storeUrl = "https://www.gog.com/en/game/${g.gameId}",
|
||||
)
|
||||
|
||||
@@ -133,6 +133,7 @@ object GogStoreCatalog {
|
||||
finalPrice = finalStr, originalPrice = if (discount > 0) baseStr else "",
|
||||
discountPercent = if (discount > 0 && baseStr.isNotBlank()) discount else 0,
|
||||
storeUrl = storeLink, developer = p.optJSONArray("developers")?.optString(0, "").orEmpty(),
|
||||
publisher = p.optJSONArray("publishers")?.optString(0, "").orEmpty(),
|
||||
releaseDate = p.optString("releaseDate", ""),
|
||||
extra = buildMap { if (slug.isNotBlank()) put("slug", slug) },
|
||||
mature = mature,
|
||||
@@ -175,7 +176,7 @@ object GogStoreCatalog {
|
||||
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
|
||||
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("final", i.finalPrice); put("orig", i.originalPrice)
|
||||
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate)
|
||||
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate)
|
||||
if (i.mature) put("mature", true)
|
||||
}
|
||||
|
||||
@@ -184,7 +185,7 @@ object GogStoreCatalog {
|
||||
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("final"), originalPrice = o.optString("orig"), discountPercent = o.optInt("disc"),
|
||||
storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"),
|
||||
storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"),
|
||||
mature = o.optBoolean("mature"),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.stores.AchievementNotifications
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
internal fun AchievementNotificationOverlay() {
|
||||
val unlock = AchievementNotifications.pending.firstOrNull()
|
||||
var visible by remember(unlock) { mutableStateOf(false) }
|
||||
LaunchedEffect(unlock) {
|
||||
if (unlock == null) return@LaunchedEffect
|
||||
visible = true
|
||||
SessionEvents.record("achievement.notification-shown", mapOf("achievementId" to unlock.id))
|
||||
delay(5_000)
|
||||
visible = false
|
||||
delay(Motion.ms(220).toLong())
|
||||
AchievementNotifications.dismiss(unlock)
|
||||
}
|
||||
Box(Modifier.fillMaxSize().padding(20.dp), contentAlignment = Alignment.BottomEnd) {
|
||||
AnimatedVisibility(
|
||||
visible, enter = fadeIn(Motion.tw(220)) + slideInVertically(Motion.tw(300)) { it / 2 },
|
||||
exit = fadeOut(Motion.tw(180)) + slideOutVertically(Motion.tw(220)) { it / 3 },
|
||||
) { if (unlock != null) AchievementNotificationCard(unlock) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AchievementNotificationCard(unlock: AchievementNotifications.Unlock) {
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
Modifier.widthIn(max = 360.dp).clip(shape).background(pal.surface.copy(alpha = 0.97f))
|
||||
.border(1.dp, pal.signal.copy(alpha = 0.65f), shape).padding(14.dp)
|
||||
.testTag("achievement-notification"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(48.dp).clip(RoundedCornerShape(10.dp)).background(pal.surfaceVariant), contentAlignment = Alignment.Center) {
|
||||
Trophy(26.dp, description = null)
|
||||
if (unlock.icon != null) AsyncImage(unlock.icon, null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Trophy(13.dp, description = null)
|
||||
Text(stringResource(R.string.achievement_unlocked).uppercase(), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.8.sp, color = pal.signal)
|
||||
}
|
||||
Text(unlock.name, color = pal.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (unlock.game.isNotBlank()) Text(unlock.game, color = pal.onSurfaceVariant, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 420, heightDp = 150)
|
||||
@Composable
|
||||
private fun AchievementNotificationPreview() = DroidDeckTheme {
|
||||
Box(Modifier.padding(20.dp)) {
|
||||
AchievementNotificationCard(AchievementNotifications.Unlock("1", "first", "First Steps", "", null, "Hollow Valley"))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,455 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Storefront
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.store.UserAppsState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// The Desktop tab in the Steam tab's shape: a tilted wall of little windows behind the word and
|
||||
// one big Open desktop, with Play's lip and lifted sheet under it (Display, Controls, Session and
|
||||
// Files). Under the hero the page scrolls to two shelves - the apps that run in the desktop, and
|
||||
// what can be added - and the hero folds into a slim header as it goes.
|
||||
|
||||
/** The desktop's own chips: its session settings, then Files. */
|
||||
private val DESKTOP_TABS = modeSheetTabs(steam = false) + SheetTab.FILES
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
|
||||
// Flathub's installed apps sit with the others on the dock.
|
||||
LaunchedEffect(s.ready) { com.droiddeck.launcher.store.StoreState.refresh(ctx) }
|
||||
val installed = s.emulators.filter { it.installed }
|
||||
val apps = UserAppsState.items
|
||||
var adding by rememberSaveable { mutableStateOf(false) }
|
||||
val mode = SessionService.MODE_DESKTOP
|
||||
val tabs = DESKTOP_TABS.map { it to sheetTabLabel(it) }
|
||||
val open = if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop)
|
||||
// Opened from a card: the page floods out of it, its picture on the tile.
|
||||
fun openFrom(at: Rect?, art: Any?, page: String) {
|
||||
at?.let { PageOrigin.mark(Origin(it, 16f * density.density, art = art)) }
|
||||
onSelect(page)
|
||||
}
|
||||
|
||||
// The Steam tab's shape, the whole pane: the dock of apps along the top of the wall, the word
|
||||
// and Open desktop at the foot, and nothing under the settings sheet but the hero itself.
|
||||
LiftHero(
|
||||
key = "desktop", tabs = tabs,
|
||||
onOpen = a.onDesktopSettings, onPrimary = a.onDesktop,
|
||||
modifier = modifier.clipToBounds(),
|
||||
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
|
||||
background = {
|
||||
DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon })
|
||||
},
|
||||
scrim = { lifted ->
|
||||
WallFade()
|
||||
// Over the fades, so the cards read at full strength; gone as the sheet lifts, so
|
||||
// nothing but the wall is behind it.
|
||||
Box(Modifier.graphicsLayer { val v = lifted(); alpha = 1f - v; translationY = -12.dp.toPx() * v }) {
|
||||
DesktopDock(s, a, installed, apps, openFrom = ::openFrom)
|
||||
}
|
||||
},
|
||||
words = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// The word at the wordmark's size.
|
||||
Text(
|
||||
stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp,
|
||||
fontWeight = FontWeight.Black, color = colors.onBackground,
|
||||
)
|
||||
BusyChip(s)
|
||||
}
|
||||
},
|
||||
button = { click ->
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click)
|
||||
},
|
||||
beside = {
|
||||
// Flathub, whose apps run in the desktop: its storefront floods out of this button.
|
||||
HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") }
|
||||
// Add your own app or game.
|
||||
HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true }
|
||||
},
|
||||
rows = { tab ->
|
||||
if (tab == SheetTab.FILES) DesktopFiles(s, a)
|
||||
else a.modeSheet(mode, tab, SheetExtras())
|
||||
},
|
||||
)
|
||||
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The dock along the top of the Desktop hero: what runs in the desktop (emulators installed, apps
|
||||
* added, Flathub's installed apps), then the emulators that can be added - one
|
||||
* row of cards that scrolls sideways, over the wall. It sinks back with the wall as the sheet lifts.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopDock(
|
||||
s: FrontEndState, a: FrontEndActions, installed: List<Library.Emulator>, apps: List<UserApps.App>,
|
||||
openFrom: (Rect?, Any?, String) -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val available = s.emulators.filter { !it.installed }
|
||||
val flatpaks = com.droiddeck.launcher.store.StoreState.installed
|
||||
val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + flatpaks.map { "flatpak:${it.id}" }
|
||||
// Where the last of your apps sits, for a new emulator's dot to land after it.
|
||||
var lastAt by remember { mutableStateOf<Rect?>(null) }
|
||||
var dockAt by remember { mutableStateOf<Rect?>(null) }
|
||||
val seen = remember { mutableSetOf<String>() }
|
||||
val mounted = remember { booleanArrayOf(false) }
|
||||
LaunchedEffect(Unit) { mounted[0] = true }
|
||||
val scroll = rememberScrollState()
|
||||
// Nothing yet (the emulators' catalog still on its way): no heading over an empty row.
|
||||
if (keys.isEmpty() && available.isEmpty() && UserAppsState.working == null && UserAppsState.lastError == null) return
|
||||
Column(Modifier.fillMaxWidth().padding(top = if (narrow) 14.dp else 22.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.user_apps_section).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = if (narrow) 20.dp else 44.dp, bottom = 8.dp),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth().onGloballyPositioned { dockAt = it.boundsInRoot() }
|
||||
.edgeFade(scroll).horizontalScroll(scroll).padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 2.dp, bottom = 4.dp),
|
||||
) {
|
||||
keys.forEachIndexed { i, k ->
|
||||
key(k) {
|
||||
// A card that arrives after the dock is up (an emulator just installed) opens in.
|
||||
val state = remember { MutableTransitionState(!mounted[0] || k in seen) }.apply { targetState = true }
|
||||
LaunchedEffect(Unit) { seen += k }
|
||||
AnimatedVisibility(state, enter = expandHorizontally(Motion.sp(0.75f)) + fadeIn(Motion.tw(300, 80))) {
|
||||
Box(if (i == keys.lastIndex) Modifier.onGloballyPositioned { lastAt = it.boundsInRoot() } else Modifier) {
|
||||
val e = installed.firstOrNull { "emu:${it.id}" == k }
|
||||
val flatpak = flatpaks.firstOrNull { "flatpak:${it.id}" == k }
|
||||
val app = apps.firstOrNull { "user:${it.key}" == k }
|
||||
if (e != null) {
|
||||
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
|
||||
DesktopCard(
|
||||
k, e.name,
|
||||
if (e.id == "retroarch") system else "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size),
|
||||
art = e.iconRes, first = i == 0,
|
||||
) { at -> openFrom(at, e.iconRes, k) }
|
||||
} else if (flatpak != null) DesktopCard(
|
||||
k, flatpak.name, flatpak.summary ?: stringResource(R.string.flathub_name),
|
||||
art = flatpak.icon ?: com.droiddeck.launcher.store.FlathubApi.iconUrl(flatpak.id), first = i == 0,
|
||||
) { at ->
|
||||
// Its page on Flathub: open, update or remove it there.
|
||||
com.droiddeck.launcher.store.StoreState.openRequest = flatpak.id
|
||||
openFrom(at, flatpak.icon, "store")
|
||||
} else if (app != null) DesktopCard(k, app.name, stringResource(app.kind.label()), art = app.icon, fallback = if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, first = i == 0) { at ->
|
||||
openFrom(at, app.icon, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Between yours and what can be added: a thin rule.
|
||||
if (keys.isNotEmpty()) Box(Modifier.padding(horizontal = 4.dp).width(1.dp).height(120.dp).background(pal.line2))
|
||||
available.forEach { e ->
|
||||
key(e.id) {
|
||||
val pkg = Library.packageId(e.id)?.let { id -> s.packages?.firstOrNull { it.id == id } }
|
||||
val busyHere = pkg != null && s.packageBusyId == pkg.id
|
||||
val canGet = pkg != null && !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning
|
||||
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
|
||||
DesktopCard(
|
||||
"emu:${e.id}", e.name, if (busyHere) (s.packageStage ?: stringResource(R.string.store_installing)) else system,
|
||||
art = e.iconRes, badge = if (busyHere) null else stringResource(R.string.comp_get),
|
||||
progress = if (busyHere) s.packagePercent else null, first = keys.isEmpty() && e == available.first(),
|
||||
) { at ->
|
||||
// Get installs it there and then, and a dot flies to where its card will be;
|
||||
// when it cannot (no runtime, a session running), its page says why.
|
||||
if (canGet && pkg != null && at != null) {
|
||||
a.onInstallPackage(pkg.id)
|
||||
val end = lastAt
|
||||
val row = dockAt
|
||||
Flights.fly(Flight(at, to = {
|
||||
val r = row ?: return@Flight null
|
||||
Offset((end?.right ?: r.left) + 70.dp.toPx(), r.center.y)
|
||||
}))
|
||||
} else openFrom(at, e.iconRes, "emu:${e.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (UserAppsState.working != null || UserAppsState.lastError != null) {
|
||||
Box(Modifier.padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 6.dp)) { UserAppsProgressLine() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A square button the big one's height beside it on the hero: an icon in [tint] (the palette's
|
||||
* signal when null). [onClick] gets its bounds, for a page to flood out of.
|
||||
*/
|
||||
@Composable
|
||||
private fun HeroIconButton(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, tint: Color?, id: String, onClick: (Rect?) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.95f else if (hot) 1.04f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "heroIcon")
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
val accent = tint ?: pal.signal
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(56.dp).paneItem("btn:$id").onGloballyPositioned { at = it.boundsInRoot() }
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape).background(if (hot) accent.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.92f))
|
||||
.glideBorder(hot, shape, accent, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(at) }
|
||||
.controllerConfirm { onClick(at) }
|
||||
.semantics { contentDescription = description },
|
||||
) { Icon(icon, null, tint = accent, modifier = Modifier.size(26.dp)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero's wall: little windows tilted and drifting as Steam's capsules do, each a window with a
|
||||
* bar along its foot and an emulator's or app's icon inside; blank windows when there are none.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopWall(icons: List<Any>) {
|
||||
val pal = LocalPalette.current
|
||||
val ground = remember(pal) { Brush.linearGradient(listOf(pal.surfaceVariant, pal.surface)) }
|
||||
TiltedWall(icons.size, if (icons.isEmpty()) 75_000 else 50_000, capW = 132.dp, capH = 84.dp, gap = 14.dp, corner = 8.dp) { i ->
|
||||
WallCapsule(ground, edge = pal.line2, icon = icons.getOrNull(i), bar = pal.background.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One thing that runs in the desktop: its icon over a blurred, darkened copy of itself, a status on
|
||||
* the art (Get, or how far an install is), and under it its name on up to two lines and what it
|
||||
* is. [onClick] gets the art's bounds, for a page to flood out of or a dot to fly from.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopCard(
|
||||
key: String, name: String, detail: String, art: Any?,
|
||||
fallback: androidx.compose.ui.graphics.vector.ImageVector = Icons.Outlined.Apps,
|
||||
first: Boolean = false, badge: String? = null, progress: Int? = null, accent: Boolean = false, tint: Color? = null,
|
||||
onClick: (Rect?) -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "deskCard")
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
var artAt by remember { mutableStateOf<Rect?>(null) }
|
||||
val width = if (LocalNarrowPane.current) 116.dp else 132.dp
|
||||
val artH = width * 0.62f
|
||||
val ground = tint?.copy(alpha = 0.25f) ?: if (accent) pal.signal.copy(alpha = 0.12f) else colors.surfaceVariant
|
||||
Column(
|
||||
Modifier.width(width).paneItem("tile:desktop:$key").then(if (first) Modifier.firstTile() else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(artAt) }
|
||||
.controllerConfirm { onClick(artAt) }
|
||||
.semantics { contentDescription = name },
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { artAt = it.boundsInRoot() }
|
||||
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(ground),
|
||||
) {
|
||||
when (art) {
|
||||
is Int -> {
|
||||
Image(
|
||||
painterResource(art), null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
|
||||
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
|
||||
Image(painterResource(art), null, modifier = Modifier.size(artH * 0.6f))
|
||||
}
|
||||
null -> Icon(fallback, null, tint = tint ?: if (accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.42f))
|
||||
else -> {
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
|
||||
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
|
||||
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.6f))
|
||||
}
|
||||
}
|
||||
Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) {
|
||||
if (progress != null) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) {
|
||||
if (progress >= 0) CircularProgressIndicator(progress = { progress / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp))
|
||||
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
else if (badge != null) Text(
|
||||
badge, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
|
||||
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(pal.signal).padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) {
|
||||
Text(name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop sheet's Files chip: the file manager and the ROMs folder (both from Setup's old
|
||||
* tools), how much the desktop takes, and Uninstall - its only home - which asks in red first.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) {
|
||||
val ctx = LocalContext.current
|
||||
SettingsGroup(stringResource(R.string.setup_tool_files)) {
|
||||
SettingsRow(stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint)) {
|
||||
SecondaryButton(stringResource(R.string.store_open), compact = true, onClick = a.onFiles)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)) {
|
||||
SecondaryButton(stringResource(R.string.setup_choose), compact = true, onClick = a.onRoms)
|
||||
}
|
||||
}
|
||||
if (s.desktopPresent) SettingsGroup(stringResource(R.string.content_linux_desktop)) {
|
||||
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } }
|
||||
SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {}
|
||||
SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) {
|
||||
UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uninstall the desktop: are you sure steps out of it in red, Keep first and focused. */
|
||||
@Composable
|
||||
private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.common_uninstall)
|
||||
val title = stringResource(R.string.desktop_uninstall_title)
|
||||
val note = stringResource(R.string.desktop_remove_text)
|
||||
val keep = stringResource(R.string.flathub_keep)
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled, compact = true) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
|
||||
handle = { Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
|
||||
) {
|
||||
StepTitle(title)
|
||||
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) {
|
||||
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
|
||||
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The add or remove under way, or why the last one failed. */
|
||||
@Composable
|
||||
private fun UserAppsProgressLine() {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val working = UserAppsState.working
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
if (working != null) {
|
||||
val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting)
|
||||
val percent = UserAppsState.percent
|
||||
Text(
|
||||
if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (percent >= 0) androidx.compose.material3.LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
} else UserAppsState.lastError?.let { Note(it) }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.ui
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -42,6 +43,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -84,11 +90,19 @@ fun DriverPage(
|
||||
// D-pad: up from the first driver lands on refresh; the page opens with the driver in use focused.
|
||||
val refreshFocus = remember { FocusRequester() }
|
||||
val selectedFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
var pageFocused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(inputMode.inputMode) {
|
||||
if (inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect
|
||||
// The chooser holds controller focus until its closing animation finishes.
|
||||
snapshotFlow { Steps.step }.first { it == null }
|
||||
focusWithinFrames({ pageFocused }) { selectedFocus }
|
||||
}
|
||||
val refreshSrc = remember { MutableInteractionSource() }
|
||||
val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value
|
||||
SettingsPage(
|
||||
host, title = title, onBack = onBack, lede = "$hint\n$status",
|
||||
modifier = Modifier.onFocusChanged { pageFocused = it.hasFocus }.focusGroup(),
|
||||
action = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.ui
|
||||
import android.view.View
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -23,6 +22,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
@@ -58,6 +58,8 @@ internal class GlideSource {
|
||||
var shape: Shape by mutableStateOf<Shape>(androidx.compose.ui.graphics.RectangleShape)
|
||||
var color by mutableStateOf(Color.Transparent)
|
||||
var width by mutableStateOf(2.dp)
|
||||
/** Focuses the control again: a step-out hands focus back to the control it came out of. */
|
||||
val requester = androidx.compose.ui.focus.FocusRequester()
|
||||
}
|
||||
|
||||
internal class FocusGlide(val view: View) {
|
||||
@@ -135,15 +137,18 @@ internal fun Modifier.glideBorder(
|
||||
onDispose { glide.release(src) }
|
||||
}
|
||||
val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this
|
||||
return base.onGloballyPositioned { src.coords = it; glide.moved(src) }
|
||||
return base.focusRequester(src.requester).onGloballyPositioned { src.coords = it; glide.moved(src) }
|
||||
}
|
||||
|
||||
/** Leading edge on a focus move: quick and a little loose. */
|
||||
private fun lead(): AnimationSpec<Float> = Motion.sp(0.62f, 700f)
|
||||
/** A spring an edge chases its target on: damping ratio and stiffness, as Compose's. */
|
||||
private class Edge(val damping: Float, val stiffness: Float)
|
||||
|
||||
private fun lead() = Edge(0.62f, 700f)
|
||||
/** Trailing edge: slower, settles without a bounce to speak of. */
|
||||
private fun trail(): AnimationSpec<Float> = Motion.sp(0.78f, 360f)
|
||||
private fun trail() = Edge(0.78f, 360f)
|
||||
/** Following a control that moves under the ring (a scroll): tight, no wobble. */
|
||||
private fun follow(): AnimationSpec<Float> = Motion.sp(1f, 2400f)
|
||||
private fun follow() = Edge(1f, 2400f)
|
||||
|
||||
/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */
|
||||
private const val SETTLE_MS = 450L
|
||||
@@ -152,6 +157,9 @@ private const val STILL_MS = 120L
|
||||
/** The longest the ring keeps checking after a move, for a control that never stands still. */
|
||||
private const val TRACK_MAX_MS = 2_000L
|
||||
|
||||
/** How strongly the ring tints what it holds. */
|
||||
private const val RING_FILL = 0.14f
|
||||
|
||||
/** Moves closer together than this are a held direction: the ring runs along as one, no stretch. */
|
||||
private const val REPEAT_MS = 180L
|
||||
|
||||
@@ -174,8 +182,16 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
val solid = remember { Animatable(0f) }
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
// An edge each, the corner, and a droplet carrying all of them.
|
||||
val jobs = remember { arrayOfNulls<Job>(6) }
|
||||
// An edge each, the corner, a droplet carrying all of them, its solid fill, and the chase.
|
||||
val jobs = remember { arrayOfNulls<Job>(8) }
|
||||
// The chase: each edge on a spring of its own toward where focus is now. A new target only
|
||||
// moves where it is heading - restarting an animation on every focus move started it from
|
||||
// its first frame (which does not move), so a held direction over a busy page (each rail item
|
||||
// swaps the page) left the ring standing while focus ran on ahead.
|
||||
val target = remember { FloatArray(4) }
|
||||
val vel = remember { FloatArray(4) }
|
||||
val springs = remember { Array(4) { follow() } }
|
||||
val startAt = remember { LongArray(4) }
|
||||
val cur = glide.current
|
||||
var lastColor by remember { mutableStateOf(Color.Transparent) }
|
||||
if (cur != null) lastColor = cur.color
|
||||
@@ -186,20 +202,53 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
LaunchedEffect(glide, Motion.scale) {
|
||||
var shown: GlideSource? = null
|
||||
var movedAt = 0L
|
||||
var dirSpecs: List<AnimationSpec<Float>> = List(4) { follow() }
|
||||
var dirSpecs: List<Edge> = List(4) { follow() }
|
||||
var delays = LongArray(4)
|
||||
var lastMove = 0L
|
||||
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, wait: Long) {
|
||||
fun go(i: Int, v: Float, spec: Edge, wait: Long) {
|
||||
// A drop cut short by another move turns back into an outline on the way.
|
||||
if (jobs[5]?.isActive == true || solid.targetValue > 0f) scope.launch { solid.animateTo(0f, Motion.tw(120)) }
|
||||
jobs[5]?.cancel()
|
||||
// Picks up the speed it is going at: restarting from rest on every step of a held
|
||||
// direction is what made it stutter.
|
||||
val speed = edges[i].velocity
|
||||
jobs[i]?.cancel()
|
||||
jobs[i] = scope.launch {
|
||||
if (wait > 0) delay(Motion.ms(wait.toInt()).toLong())
|
||||
edges[i].animateTo(v, spec, initialVelocity = if (wait > 0) 0f else speed)
|
||||
if (jobs[5]?.isActive == true || solid.targetValue > 0f) {
|
||||
jobs[5]?.cancel()
|
||||
jobs[6]?.cancel()
|
||||
jobs[6] = scope.launch { solid.animateTo(0f, Motion.tw(120)) }
|
||||
}
|
||||
val chasing = jobs[7]?.isActive == true
|
||||
// An edge some other animation was moving (the appear, a drop's opening) is taken over
|
||||
// at the speed it was going.
|
||||
jobs[i]?.let { if (it.isActive) { it.cancel(); vel[i] = edges[i].velocity } }
|
||||
if (!chasing) for (j in 0..3) if (jobs[j]?.isActive != true) vel[j] = edges[j].velocity
|
||||
target[i] = v
|
||||
springs[i] = spec
|
||||
startAt[i] = System.nanoTime() + Motion.ms(wait.toInt()) * 1_000_000L
|
||||
if (chasing) return
|
||||
jobs[7] = scope.launch {
|
||||
var last = withFrameNanos { it }
|
||||
while (true) {
|
||||
val frame = withFrameNanos { it }
|
||||
// Real time, however long the frame took, in small steps so a stiff spring holds.
|
||||
val dt = ((frame - last) / 1e9f).coerceAtMost(0.1f) / Motion.scale.coerceAtLeast(0.01f)
|
||||
last = frame
|
||||
val now = System.nanoTime()
|
||||
var settled = true
|
||||
for (j in 0..3) {
|
||||
if (now < startAt[j]) { settled = false; continue }
|
||||
val k = springs[j].stiffness
|
||||
val c = 2f * springs[j].damping * kotlin.math.sqrt(k)
|
||||
var x = edges[j].value
|
||||
var u = vel[j]
|
||||
var left = dt
|
||||
while (left > 0f) {
|
||||
val h = minOf(left, 0.004f)
|
||||
u += (-k * (x - target[j]) - c * u) * h
|
||||
x += u * h
|
||||
left -= h
|
||||
}
|
||||
if (abs(x - target[j]) < 0.3f && abs(u) < 3f) { x = target[j]; u = 0f } else settled = false
|
||||
vel[j] = u
|
||||
edges[j].snapTo(x)
|
||||
}
|
||||
if (settled) break
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Pinch into a solid dot where it is, carry it over, and open it into [b] as it arrives. */
|
||||
@@ -270,6 +319,8 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
// Focus passing between two controls can let go of one a moment before the next
|
||||
// takes it: wait a beat, so that reads as a move and not a fade out and back in.
|
||||
delay(60)
|
||||
jobs.forEach { it?.cancel() }
|
||||
solid.snapTo(0f)
|
||||
scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
|
||||
shown = null
|
||||
return@collectLatest
|
||||
@@ -296,7 +347,7 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out)
|
||||
jobs.forEach { it?.cancel() }
|
||||
scope.launch { solid.snapTo(0f); edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join()
|
||||
dirSpecs = List(4) { Motion.sp(0.6f, 700f) }
|
||||
dirSpecs = List(4) { Edge(0.6f, 700f) }
|
||||
delays = LongArray(4)
|
||||
cornerTo(src, b, snap = true)
|
||||
scope.launch { alpha.animateTo(1f, Motion.tw(140)) }
|
||||
@@ -315,8 +366,8 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
if (dropped) droplet(src, b)
|
||||
else {
|
||||
// A held direction runs along as one piece; a single press stretches.
|
||||
dirSpecs = if (held) List(4) { Motion.sp(0.9f, 900f) }
|
||||
else List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } }
|
||||
dirSpecs = if (held) List(4) { Edge(0.9f, 900f) }
|
||||
else List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Edge(0.7f, 520f) } }
|
||||
delays = LongArray(4) { i -> if (i == trailIdx && !held) 30L else 0L }
|
||||
cornerTo(src, b, snap = false)
|
||||
}
|
||||
@@ -359,10 +410,18 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
color, topLeft = Offset(l, t), size = Size(r - l, b - t),
|
||||
cornerRadius = CornerRadius(corner.value), alpha = a * f,
|
||||
)
|
||||
if (f < 0.99f) drawRoundRect(
|
||||
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
|
||||
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
|
||||
)
|
||||
if (f < 0.99f) {
|
||||
// The ring's own infill: the only fill a pad sees, so it is always exactly what
|
||||
// the ring holds and moves with it (a row's own sliding fill trailed it).
|
||||
drawRoundRect(
|
||||
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
|
||||
cornerRadius = CornerRadius(rad), alpha = a * (1f - f) * RING_FILL,
|
||||
)
|
||||
drawRoundRect(
|
||||
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
|
||||
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(LocalFocusGlide provides glide) { content() }
|
||||
|
||||
@@ -56,9 +56,21 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil.compose.AsyncImage
|
||||
import java.io.File
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
|
||||
// Cover art: posters, the art grid and its tiles.
|
||||
|
||||
/** Where each art tile sits, root px, by its key: a page opened from one floods out of it. */
|
||||
internal object TileBounds {
|
||||
val at = HashMap<String, androidx.compose.ui.geometry.Rect>()
|
||||
|
||||
/** The page about to open came from tile [key] (its art or icon for the flood), if it is on screen. */
|
||||
fun mark(key: String, art: Any?, corner: Float) {
|
||||
at[key]?.let { PageOrigin.mark(Origin(it, corner, art = art)) }
|
||||
}
|
||||
}
|
||||
|
||||
internal class Tile(
|
||||
val title: String, val sub: String?, val art: File?, val key: String,
|
||||
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
|
||||
@@ -102,6 +114,7 @@ internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
|
||||
.onGloballyPositioned { TileBounds.at[t.key] = it.boundsInRoot() }
|
||||
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -124,15 +126,30 @@ internal fun Pane(
|
||||
val paneAt = remember { arrayOf(Offset.Zero) }
|
||||
val livePage by rememberUpdatedState(s.pageKey)
|
||||
// Picking another game changes the detail beside the list, not the whole page.
|
||||
val target = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected
|
||||
val railContent = if (selected.startsWith("app:")) "games" else selected
|
||||
val target = if (page != null && s.pageKey != null) s.pageKey else railContent
|
||||
// The game the Games page shows. It must outlive the selection: while the page animates out,
|
||||
// drawing it with what is selected now (another rail item) turned it into a copy of that page.
|
||||
val gameShown = remember { arrayOf("games") }
|
||||
if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected
|
||||
// What the pane showed before this change: the content a page opened over belongs to it.
|
||||
val before = remember { arrayOf(target) }
|
||||
if (page != null && s.pageKey != null) {
|
||||
pages[s.pageKey] = page
|
||||
if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]) }
|
||||
if (s.pageKey !in origins) PageOrigin.take()?.let { o ->
|
||||
// A hop's page came out of its own rail item, and is that item's content.
|
||||
origins[s.pageKey] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(railContent) else it }
|
||||
}
|
||||
} else if (target != before[0] && target !in origins) {
|
||||
// A section's own page opened from a control in it (an emulator's card, a ROM's) floods
|
||||
// out of that control and belongs to what was showing; a hop's comes out of its rail item.
|
||||
PageOrigin.take()?.let { o -> origins[target] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(before[0]) else it } }
|
||||
}
|
||||
before[0] = target
|
||||
val targetNow by rememberUpdatedState(target)
|
||||
// A page leaving through a hop's link is not coming back to its control.
|
||||
val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary
|
||||
LaunchedEffect(target) { Hops.linking = false }
|
||||
AnimatedContent(
|
||||
targetState = target,
|
||||
modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() },
|
||||
@@ -143,9 +160,14 @@ internal fun Pane(
|
||||
.togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f))
|
||||
.apply { targetContentZIndex = 1f }
|
||||
// Back out of it: the flood draws back into the control as the pane comes forward again.
|
||||
initialState in origins -> (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
|
||||
.togetherWith(ExitTransition.None)
|
||||
.apply { targetContentZIndex = -1f }
|
||||
initialState in origins && pageExit(origins[initialState]?.owner, targetState, leave) == PageExit.Drain ->
|
||||
(fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
|
||||
.togetherWith(ExitTransition.None)
|
||||
.apply { targetContentZIndex = -1f }
|
||||
// Away to something else: the control is not coming back, so the page sinks with the pane.
|
||||
initialState in origins -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
|
||||
.togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.95f))
|
||||
.apply { targetContentZIndex = 1f }
|
||||
else -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
|
||||
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
|
||||
.apply { targetContentZIndex = 1f }
|
||||
@@ -153,26 +175,51 @@ internal fun Pane(
|
||||
},
|
||||
label = "pane",
|
||||
) { key ->
|
||||
val shown = if (page != null && key == s.pageKey) page else pages[key]
|
||||
if (shown != null) {
|
||||
DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } }
|
||||
val drawn = (if (page != null && key == s.pageKey) page else pages[key])
|
||||
?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) }
|
||||
// A hop leaving this content: it sinks back as the dot leaves it (and comes forward
|
||||
// again, freshly drawn, when the hop is undone).
|
||||
val hopSink by androidx.compose.animation.core.animateFloatAsState(
|
||||
if (Hops.sinking == key) 1f else 0f, Motion.tw(260), label = "hopSink",
|
||||
)
|
||||
val shown: @Composable () -> Unit = {
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * hopSink; scaleX = k; scaleY = k; alpha = 1f - 0.35f * hopSink }) { drawn() }
|
||||
}
|
||||
run {
|
||||
DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } }
|
||||
val from = origins[key]
|
||||
if (from == null) shown()
|
||||
else {
|
||||
// The control's own picture (a card's art, an emulator's icon) on the tile as it grows.
|
||||
val art = when (val m = from.art) {
|
||||
null -> null
|
||||
is Int -> androidx.compose.ui.res.painterResource(m)
|
||||
else -> coil.compose.rememberAsyncImagePainter(m)
|
||||
}
|
||||
val leaving = transition.targetState == EnterExitState.PostExit
|
||||
// Settled as the page starts to leave, from what is coming in, not from where it came in.
|
||||
val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain }
|
||||
// Keeps a leaving page on screen while its flood draws back into the control.
|
||||
transition.animateFloat(
|
||||
transitionSpec = { if (targetState == EnterExitState.PostExit) Motion.tw(PAGE_RETURN_MS) else snap() },
|
||||
transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() },
|
||||
label = "pageReturn",
|
||||
) { if (it == EnterExitState.PostExit) 1f else 0f }
|
||||
PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() }
|
||||
PageFlood(from, leaving = leaving, art = art, sinks = !drains) { shown() }
|
||||
}
|
||||
}
|
||||
else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An origin left of the pane (a rail item a hop flew to) starts half inside it, at the item's
|
||||
* height: the page swells out of the pane's edge beside the item. Wholly outside, its tile was a
|
||||
* bar that raced across from the far edge.
|
||||
*/
|
||||
internal fun inPane(o: Origin): Origin =
|
||||
if (o.bounds.right > 0f) o else o.translate(Offset(-o.bounds.right + o.bounds.width / 2f, 0f))
|
||||
|
||||
@Composable
|
||||
private fun Backdrop(art: File?) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -207,6 +254,7 @@ private fun Content(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
|
||||
val narrow = LocalNarrowPane.current
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current.density
|
||||
val padH = if (narrow) 16.dp else 22.dp
|
||||
val padV = if (narrow) 12.dp else 18.dp
|
||||
// Setup scrolls inside itself, under its tabs.
|
||||
@@ -226,12 +274,12 @@ private fun Content(
|
||||
return
|
||||
}
|
||||
// The Store scrolls and navigates inside itself (its tabs and an app's page).
|
||||
if (selected == "store" && s.storeEnabled) {
|
||||
if (selected == "store") {
|
||||
StorePage(s, a, modifier)
|
||||
return
|
||||
}
|
||||
// Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it.
|
||||
if (selected == "stores" && s.gameStoresEnabled) {
|
||||
if (selected == "stores") {
|
||||
StoresPage(s, a, modifier)
|
||||
return
|
||||
}
|
||||
@@ -240,6 +288,11 @@ private fun Content(
|
||||
GamesPage(s, a, selected, onSelect, modifier)
|
||||
return
|
||||
}
|
||||
// Desktop is the Steam tab's shape: a hero with its lifted sheet, and shelves under it.
|
||||
if (selected == "desktop") {
|
||||
DesktopPage(s, a, modifier, onSelect)
|
||||
return
|
||||
}
|
||||
// Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid
|
||||
// instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view.
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
|
||||
@@ -260,37 +313,6 @@ private fun Content(
|
||||
})
|
||||
}
|
||||
}
|
||||
selected == "desktop" -> {
|
||||
val installed = s.emulators.filter { it.installed }
|
||||
val available = s.emulators.filter { !it.installed }
|
||||
val ctx = LocalContext.current
|
||||
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
|
||||
val apps = UserAppsState.items
|
||||
var adding by rememberSaveable { mutableStateOf(false) }
|
||||
Rise(0) {
|
||||
PageHeader(stringResource(R.string.drawer_desktop)) {
|
||||
if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false)
|
||||
}
|
||||
}
|
||||
Rise(2) { DesktopCard(s, a) }
|
||||
// Add closes the last grid: the installable emulators, or the installed ones once none are left.
|
||||
val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) }
|
||||
val addAfterReady = available.isEmpty()
|
||||
if (ready.isNotEmpty() || addAfterReady) {
|
||||
Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) }
|
||||
Rise(4) {
|
||||
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
|
||||
}
|
||||
}
|
||||
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
|
||||
if (!addAfterReady) {
|
||||
Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) }
|
||||
Rise(6) {
|
||||
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
|
||||
}
|
||||
}
|
||||
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
|
||||
}
|
||||
selected.startsWith("user:") -> {
|
||||
val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected }
|
||||
if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect)
|
||||
@@ -300,21 +322,26 @@ private fun Content(
|
||||
if (e == null) Note(stringResource(R.string.content_not_installed)) else {
|
||||
val pkgId = Library.packageId(e.id)
|
||||
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
|
||||
Rise(0) {
|
||||
BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") }
|
||||
}
|
||||
// Out of its card on Desktop, with the card's icon; B draws it back in.
|
||||
Rise(1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
|
||||
) {
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(64.dp))
|
||||
Column {
|
||||
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(e.name, fontSize = if (narrow) 24.sp else 30.sp, fontWeight = FontWeight.Black, color = colors.onBackground)
|
||||
Text(
|
||||
systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (e.installed) Text(
|
||||
listOfNotNull(
|
||||
pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size).takeIf { e.id != "retroarch" || e.games.isNotEmpty() },
|
||||
s.romsDir,
|
||||
).joinToString(" · "),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,7 +366,24 @@ private fun Content(
|
||||
)
|
||||
}
|
||||
else Rise(5, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
|
||||
Column {
|
||||
// RetroArch's games come from many systems: chips filter them, one
|
||||
// selection gliding between them.
|
||||
val systems = remember(e.games) { e.games.map { romSystem(it) }.distinct() }
|
||||
var system by rememberSaveable(e.id) { mutableStateOf<String?>(null) }
|
||||
if (systems.size > 1) Box(Modifier.padding(top = 10.dp)) {
|
||||
val labels = listOf(stringResource(R.string.desktop_all_systems)) + systems
|
||||
SheetChips(labels, if (system == null) 0 else systems.indexOf(system) + 1) { i -> system = if (i == 0) null else systems[i - 1] }
|
||||
}
|
||||
val shown = e.games.withIndex().filter { system == null || romSystem(it.value) == system }
|
||||
ArtGrid(shown.map { (index, g) ->
|
||||
val key = "rom:${e.id}:$index"
|
||||
Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, key, e.iconRes) {
|
||||
TileBounds.mark(key, g.art ?: e.iconRes, 12f * density)
|
||||
onSelect(key)
|
||||
}
|
||||
}, wide = e.games.none { it.art != null })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pkg != null) Rise(2) {
|
||||
@@ -381,12 +425,7 @@ private fun Content(
|
||||
val pair = romFor(s, selected)
|
||||
if (pair == null) Note(stringResource(R.string.content_rom_gone)) else {
|
||||
val (e, g) = pair
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
BackLink(e.name) { onSelect("emu:${e.id}") }
|
||||
Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e)))
|
||||
}
|
||||
}
|
||||
Rise(0) { Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) }
|
||||
Rise(1) { Title(g.name) }
|
||||
Rise(2) {
|
||||
Row {
|
||||
@@ -408,7 +447,10 @@ private fun Content(
|
||||
Rise(4, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(others.map { x ->
|
||||
val index = e.games.indexOf(x)
|
||||
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
|
||||
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) {
|
||||
TileBounds.mark("rom:${e.id}:$index", x.art ?: e.iconRes, 12f * density)
|
||||
onSelect("rom:${e.id}:$index")
|
||||
}
|
||||
}, wide = others.none { it.art != null })
|
||||
}
|
||||
}
|
||||
@@ -419,122 +461,6 @@ private fun Content(
|
||||
}
|
||||
}
|
||||
|
||||
/** The Linux desktop itself, above the emulators that run in it. */
|
||||
@Composable
|
||||
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var confirmRemove by rememberSaveable { mutableStateOf(false) }
|
||||
if (confirmRemove) ConfirmDialog(
|
||||
stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text),
|
||||
stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false },
|
||||
)
|
||||
val actions: @Composable () -> Unit = {
|
||||
Actions {
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
|
||||
Cog(onClick = a.onDesktopSettings)
|
||||
if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall),
|
||||
enabled = !s.runtimeActionsBlocked) { confirmRemove = true }
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
// The button goes under the title unless the card is wide enough for both side by side: in some
|
||||
// languages its label alone takes half a small phone's pane.
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
|
||||
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!narrow) actions()
|
||||
}
|
||||
if (narrow) actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How wide the desktop card must be to keep its button beside the title. */
|
||||
private val DesktopCardInlineWidth = 640.dp
|
||||
|
||||
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
|
||||
private sealed class GridItem(val key: String) {
|
||||
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
|
||||
class User(val app: UserApps.App) : GridItem("user:${app.key}")
|
||||
data object Add : GridItem("add")
|
||||
}
|
||||
|
||||
/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */
|
||||
@Composable
|
||||
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
// A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the
|
||||
// name no room at all.
|
||||
val columns = when {
|
||||
LocalNarrowPane.current -> 1
|
||||
maxWidth < 720.dp -> 2
|
||||
else -> 3
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
|
||||
items.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
row.forEachIndexed { i, item ->
|
||||
key(item.key) {
|
||||
val m = Modifier.weight(1f).fillMaxHeight()
|
||||
val isFirst = first && r == 0 && i == 0
|
||||
when (item) {
|
||||
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
|
||||
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
|
||||
GridItem.Add -> AddTile(m, isFirst, onAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A grid tile's frame: the emulator tiles' look, focus glide and press. */
|
||||
@Composable
|
||||
private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("tile:$id").then(if (isFirst) Modifier.firstTile() else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (filled) colors.surface else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileText(title: String, detail: String, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun UserApps.Kind.label() = when (this) {
|
||||
UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script
|
||||
UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage
|
||||
@@ -556,26 +482,6 @@ private fun UserAppIcon(app: UserApps.App, size: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) =
|
||||
TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) {
|
||||
UserAppIcon(app, 44)
|
||||
TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f))
|
||||
}
|
||||
|
||||
/** The grid's last tile: opens the Add dialog. */
|
||||
@Composable
|
||||
private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12),
|
||||
) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) }
|
||||
TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
/** The add or remove under way, or why the last one failed. */
|
||||
@Composable
|
||||
private fun UserAppsProgress() {
|
||||
@@ -595,20 +501,17 @@ private fun UserAppsProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */
|
||||
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (asked first). */
|
||||
@Composable
|
||||
private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ctx = LocalContext.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
var confirm by remember(app.key) { mutableStateOf(false) }
|
||||
var editing by rememberSaveable(app.key) { mutableStateOf(false) }
|
||||
val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF
|
||||
val fexReady by produceState<Boolean?>(null, app.key, x86, s.sessionRunning) {
|
||||
value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true
|
||||
}
|
||||
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
|
||||
Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } }
|
||||
Rise(1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -631,12 +534,8 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
|
||||
BusyChip(s)
|
||||
SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true }
|
||||
if (app.repo != null) UpdateButton(app, s)
|
||||
SecondaryButton(
|
||||
stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove),
|
||||
enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null,
|
||||
) {
|
||||
if (!confirm) confirm = true
|
||||
else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") }
|
||||
RemoveAppButton(app, enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null) {
|
||||
UserAppsState.remove(ctx, app); onSelect("desktop")
|
||||
}
|
||||
if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false)
|
||||
}
|
||||
@@ -657,6 +556,31 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove, which asks first: are you sure steps out of it in red, Keep first and focused. */
|
||||
@Composable
|
||||
private fun RemoveAppButton(app: UserApps.App, enabled: Boolean, onRemove: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.user_apps_remove)
|
||||
val title = stringResource(R.string.flathub_remove_title, app.name)
|
||||
val keep = stringResource(R.string.flathub_keep)
|
||||
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 2,
|
||||
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
|
||||
) {
|
||||
StepTitle(title)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(1)) {
|
||||
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
|
||||
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onRemove() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks up a newer release of an app added from GitHub, then installs it. */
|
||||
@Composable
|
||||
private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
|
||||
@@ -688,21 +612,5 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
|
||||
private fun systemLabel(e: Library.Emulator): String =
|
||||
if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@Composable
|
||||
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val system = systemLabel(e)
|
||||
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
|
||||
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
|
||||
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!e.installed) Text(
|
||||
stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
/** The system a ROM belongs to: the folder it sits in under the ROMs folder. */
|
||||
private fun romSystem(rom: Library.Rom): String = rom.hostPath.parentFile?.name.orEmpty()
|
||||
@@ -77,6 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
|
||||
// The front end's side rail: the page list and the resume entry under it.
|
||||
@@ -103,27 +106,53 @@ internal fun SideRail(
|
||||
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
|
||||
) {
|
||||
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
|
||||
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
|
||||
val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
|
||||
// A hop in flight moves the selection ahead of the page it is bringing.
|
||||
val shown = Hops.railPreview ?: selected
|
||||
val keys = buildList {
|
||||
add("steam"); add("games")
|
||||
if (s.gameStoresEnabled) add("stores")
|
||||
add("desktop")
|
||||
add("components")
|
||||
if (s.isHomeApp) add("android-apps")
|
||||
add("setup"); add("updates")
|
||||
}
|
||||
// Where each item sits in the column, for the selection that glides between them.
|
||||
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, androidx.compose.ui.geometry.Rect>() }
|
||||
val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal)
|
||||
var railAt by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).onGloballyPositioned { railAt = it.boundsInRoot().topLeft }) {
|
||||
// One selection for the whole rail, under the items: it glides from item to item as the
|
||||
// chips' does, its leading edge first, and a jump of more than one item goes as a drop.
|
||||
if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown]?.translate(-railAt), tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
|
||||
val place: (String) -> Modifier = { key ->
|
||||
// In root px: an item that comes and goes sits in its own wrapper, not the column.
|
||||
Modifier.onGloballyPositioned {
|
||||
bounds[key] = it.boundsInRoot()
|
||||
Hops.railBounds[key] = it.boundsInRoot()
|
||||
}
|
||||
}
|
||||
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", shown == "steam", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
|
||||
// Always there, so the items below it never move; an empty library says how to fill it.
|
||||
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
|
||||
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", shown == "games", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("games") }) { onSelect("games") }
|
||||
// The game stores, right under Games: what they install lands there. Its badge counts
|
||||
// the downloads in flight, so a download started here is never out of sight.
|
||||
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
|
||||
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
|
||||
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
|
||||
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
|
||||
// A section switched on in Setup comes in as the rail's own entrance; off, it folds away.
|
||||
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
|
||||
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
|
||||
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") }
|
||||
// Home mode's extra section goes last, so it shifts nothing above it.
|
||||
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
|
||||
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
|
||||
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
|
||||
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", shown == "android-apps", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
|
||||
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", shown == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, place = place, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
|
||||
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", shown == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, place = place, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
|
||||
}
|
||||
}
|
||||
}
|
||||
var lastRunning by remember { mutableStateOf("") }
|
||||
@@ -142,6 +171,8 @@ private fun RailItem(
|
||||
badge: Boolean = false,
|
||||
/** A number on the item (downloads in flight); 0 shows nothing. */
|
||||
count: Int = 0,
|
||||
/** Reports where the item sits, for the rail's gliding selection and a hop's dot. */
|
||||
place: (String) -> Modifier = { Modifier },
|
||||
onFocus: () -> Unit = {}, onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -162,8 +193,9 @@ private fun RailItem(
|
||||
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
|
||||
Motion.tw(220), label = "railFg",
|
||||
)
|
||||
// The selected look is the rail's glide under the item, so the item draws only hover here.
|
||||
val fill by animateColorAsState(
|
||||
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
|
||||
if (current) Color.Transparent else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
|
||||
Motion.tw(220), label = "railFill",
|
||||
)
|
||||
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
|
||||
@@ -172,6 +204,9 @@ private fun RailItem(
|
||||
modifier = Modifier
|
||||
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
|
||||
.testTag("rail-$key")
|
||||
.then(place(key))
|
||||
// Pops as a hop's dot lands on it.
|
||||
.landingPop(Hops.railLanded[key] ?: 0)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
|
||||
.clip(Shape14)
|
||||
|
||||
@@ -70,6 +70,8 @@ import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
@@ -132,10 +134,7 @@ class FrontEndState(
|
||||
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
|
||||
val launcherFullscreen: Boolean = true,
|
||||
val animationsEnabled: Boolean = true,
|
||||
/** The Flathub Store, a beta the user turns on in Setup. */
|
||||
val storeEnabled: Boolean = false,
|
||||
/** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */
|
||||
val gameStoresEnabled: Boolean = false,
|
||||
val gameStoresEnabled: Boolean = true,
|
||||
/** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */
|
||||
val gameStoresSpeedTier: String = "fast",
|
||||
/** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */
|
||||
@@ -178,8 +177,12 @@ class FrontEndActions(
|
||||
val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {},
|
||||
val onRom: (Library.Rom) -> Unit,
|
||||
val onResume: () -> Unit,
|
||||
/** Reads Steam's session settings for its lifted sheet (under Play). */
|
||||
val onSteamSettings: () -> Unit,
|
||||
/** Reads the desktop's session settings for its lifted sheet (under Open desktop). */
|
||||
val onDesktopSettings: () -> Unit,
|
||||
/** A lifted sheet's rows for one tab of [mode]'s settings, as last read. */
|
||||
val modeSheet: @Composable (mode: String, tab: String, extras: SheetExtras) -> Unit = { _, _, _ -> },
|
||||
val onRemoveDesktop: () -> Unit = {},
|
||||
val onInstallPackage: (String) -> Unit,
|
||||
val onRemovePackage: (String) -> Unit,
|
||||
@@ -189,6 +192,8 @@ class FrontEndActions(
|
||||
val onProtons: () -> Unit,
|
||||
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
|
||||
val onComponents: (focusContent: Boolean) -> Unit,
|
||||
/** The Components page on one layer ([GPU_TAB], a component id): a hop's destination. */
|
||||
val onComponentsTab: (String) -> Unit = {},
|
||||
/** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */
|
||||
val onSaveImport: (Library.SteamGame) -> Unit = {},
|
||||
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
|
||||
@@ -208,7 +213,6 @@ class FrontEndActions(
|
||||
val onOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onStoreEnabled: (Boolean) -> Unit = {},
|
||||
val onGameStoresEnabled: (Boolean) -> Unit = {},
|
||||
val onGameStoresSpeedTier: (String) -> Unit = {},
|
||||
val onStoresOpenTab: (String) -> Unit = {},
|
||||
@@ -337,6 +341,8 @@ internal class FrontFocus {
|
||||
/** Down from this button goes to the first tile of the page's grid, when there is one. */
|
||||
@Composable
|
||||
internal fun Modifier.downToFirstTile(): Modifier {
|
||||
// Sheet rows walk their settings; Desktop's dock stays composed behind the sheet.
|
||||
if (LocalSheetBeat.current != null) return this
|
||||
val ff = LocalFrontFocus.current ?: return this
|
||||
return this.focusProperties { down = if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default }
|
||||
}
|
||||
@@ -382,6 +388,18 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks [target] for focus on the next frames until a request lands (a control can take focus only
|
||||
* once it is laid out), for at most half a second; then leaves focus alone, so a press right after
|
||||
* is never pulled back.
|
||||
*/
|
||||
internal suspend fun focusSoon(target: () -> FocusRequester) {
|
||||
repeat(30) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
if (runCatching { target().requestFocus() }.isSuccess) return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
|
||||
val frontFocus = remember { FrontFocus() }
|
||||
@@ -423,19 +441,27 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() }
|
||||
// Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link
|
||||
// does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop.
|
||||
// A page reached by a hop goes back the way it came; any other page just closes.
|
||||
val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() }
|
||||
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() }
|
||||
// Left any other way, the hop's page has no way back to keep.
|
||||
LaunchedEffect(s.pageKey, selected) { Hops.forget(s.pageKey ?: selected) }
|
||||
// A section reached by a hop (Desktop's Flathub card to the Store) goes back the way it came.
|
||||
BackHandler(enabled = !processSettingsPageVisible && s.pageKey == null && Hops.origin?.page == selected) {
|
||||
val rail = Hops.origin?.rail ?: return@BackHandler
|
||||
Hops.back { selected = rail }
|
||||
}
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current.density
|
||||
remember(density) { Hops.density = density; true }
|
||||
// Back (and B) from a ROM, an emulator, an added app or Flathub steps out one level instead
|
||||
// of leaving the app: a ROM -> its emulator, the others -> Desktop.
|
||||
BackHandler(
|
||||
enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) &&
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")),
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"),
|
||||
) {
|
||||
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
|
||||
}
|
||||
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
|
||||
// The Store turned off in Setup takes its page with it; so does Stores.
|
||||
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
|
||||
LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" }
|
||||
// The last game uninstalled leaves the Games tab on its empty state.
|
||||
LaunchedEffect(s.steamGames.isEmpty()) {
|
||||
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
|
||||
@@ -443,11 +469,14 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
remember { Motion.refresh(ctx); true }
|
||||
|
||||
val railSelection = when {
|
||||
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
|
||||
s.pageKey?.startsWith("settings:steam") == true -> "steam"
|
||||
s.pageKey?.startsWith("settings:") == true -> "desktop"
|
||||
// A hop's page belongs to the rail item it flew to.
|
||||
Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest
|
||||
// Performance opens from Play's sheet, and is Steam's.
|
||||
s.pageKey == "performance" -> "steam"
|
||||
s.pageKey == "controller-mapping" -> "setup"
|
||||
selected.startsWith("app:") -> "games"
|
||||
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
|
||||
// Flathub is a page of Desktop's, as an emulator's is.
|
||||
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store" -> "desktop"
|
||||
else -> s.pageKey ?: selected
|
||||
}
|
||||
// Bumped each time a rail item is picked, so a controller moves on into the new page.
|
||||
@@ -458,6 +487,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
if (key == "components") a.onComponents(focusContent)
|
||||
else {
|
||||
if (s.pageKey != null) a.onPageBack()
|
||||
// Another section: no way back to keep, and no sheet to put back.
|
||||
Hops.origin = null
|
||||
Hops.restore = null
|
||||
Hops.sinking = null
|
||||
selected = key
|
||||
}
|
||||
}
|
||||
@@ -470,6 +503,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
val inputModeManager = LocalInputModeManager.current
|
||||
val window = LocalWindowInfo.current
|
||||
val androidContext = androidx.compose.ui.platform.LocalContext.current
|
||||
var anyFocused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(colors.background)
|
||||
@@ -501,7 +535,15 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
if (processSettingsPageVisible) return@LaunchedEffect
|
||||
// Past the old page's exit (170ms), then the first frame the new page takes focus.
|
||||
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
|
||||
if (anyFocused || inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
|
||||
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
|
||||
if (s.pageKey == "performance") {
|
||||
val back = "back:${androidContext.getString(R.string.mode_back)}"
|
||||
focusWithinFrames({ frontFocus.last == back && anyFocused && frontFocus.focusedRail == null }) {
|
||||
frontFocus.items[back] ?: FocusRequester.Default
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (anyFocused) return@LaunchedEffect
|
||||
focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) }
|
||||
}
|
||||
// A rail item picked with a controller moves on into its page, once the page is in.
|
||||
@@ -519,7 +561,22 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
val railFocus = Modifier
|
||||
.focusProperties { enter = { frontFocus.railFor(railSelection) } }
|
||||
.focusGroup()
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Back from a hop onto a surface that is not a sheet (a game's Components card, Desktop's
|
||||
// Flathub card): focus
|
||||
// goes back to the control that asked, once the page has gone.
|
||||
LaunchedEffect(Hops.restore) {
|
||||
val r = Hops.restore ?: return@LaunchedEffect
|
||||
if (r.surface == HOP_SURFACE_SHEET) return@LaunchedEffect
|
||||
Hops.restore = null
|
||||
if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail
|
||||
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
|
||||
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
|
||||
frontFocus.last = r.control
|
||||
focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() }
|
||||
}
|
||||
var screenAt by remember { mutableStateOf(Offset.Zero) }
|
||||
CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) {
|
||||
Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) {
|
||||
SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight())
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
@@ -535,6 +592,12 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Dots on their way somewhere: a hop to another section and back, a swap flying home.
|
||||
FlightsLayer(screenAt)
|
||||
// A confirm stepping out of the control that asked for it.
|
||||
StepsLayer(screenAt)
|
||||
|
||||
appToChooseDisplay?.let { app ->
|
||||
val secondaryDisplay = s.secondScreenDisplays.firstOrNull()
|
||||
ChooseAppDisplayDialog(
|
||||
@@ -597,8 +660,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
}
|
||||
val hasBackTarget = (s.pageKey != null && page != null) ||
|
||||
// A section reached by a hop goes back to where it came from, not to the rail.
|
||||
(s.pageKey == null && Hops.origin?.page == selected) ||
|
||||
((s.pageKey == null || page == null) &&
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")))
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"))
|
||||
// At the top of a section, Back goes to the rail - the launcher itself is never backed out of.
|
||||
BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) {
|
||||
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
|
||||
|
||||
@@ -71,6 +71,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.AppLanguage
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
@@ -166,21 +168,26 @@ internal fun SetupPanel(
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
var editSgdbKey by remember { mutableStateOf(false) }
|
||||
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
|
||||
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
|
||||
// and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
|
||||
// Four tabs by scope, a chip row with one gliding selection; LB and RB turn them from
|
||||
// anywhere on the page. Build and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics))
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
|
||||
val chipFocus = remember { List(4) { FocusRequester() } }
|
||||
var tabTurned by remember { mutableStateOf(false) }
|
||||
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
|
||||
val inputModeManager = LocalInputModeManager.current
|
||||
// The control a controller was on went with the old tab: it lands on the new tab itself.
|
||||
// The control a controller was on went with the old tab: it lands on the new tab's chip.
|
||||
LaunchedEffect(tab) {
|
||||
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { tabFocus[tab].requestFocus() }
|
||||
runCatching { chipFocus[tab].requestFocus() }
|
||||
}
|
||||
}
|
||||
val wide = !LocalNarrowPane.current
|
||||
val runtimeOk = s.ready && !s.busy && !s.removalPending
|
||||
val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn)
|
||||
val passing = checks.count { it }
|
||||
var showChecks by rememberSaveable { mutableStateOf(false) }
|
||||
Rise(0, Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().bumpers(
|
||||
@@ -189,15 +196,12 @@ internal fun SetupPanel(
|
||||
),
|
||||
) {
|
||||
PageHeader(stringResource(R.string.setup_title)) {
|
||||
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
|
||||
Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, pick) }
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
SectionTitle(stringResource(R.string.setup_system_check), null)
|
||||
// What Steam needs, one row each: green when done, one button when not. The
|
||||
// process-limit controls only open under their row.
|
||||
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
val gpuCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
CheckRow(
|
||||
if (gpuOk) CheckState.OK else CheckState.WARN,
|
||||
when (gpu.support) {
|
||||
@@ -210,7 +214,10 @@ internal fun SetupPanel(
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() })
|
||||
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
|
||||
},
|
||||
divider = divider,
|
||||
)
|
||||
}
|
||||
val runtimeCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
CheckRow(
|
||||
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
|
||||
stringResource(R.string.setup_runtime),
|
||||
@@ -221,7 +228,7 @@ internal fun SetupPanel(
|
||||
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
|
||||
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
|
||||
},
|
||||
divider = !s.busy,
|
||||
divider = divider && !s.busy,
|
||||
) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) }
|
||||
if (s.busy) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
|
||||
@@ -232,8 +239,10 @@ internal fun SetupPanel(
|
||||
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
val limitCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
CheckRow(
|
||||
if (limitBlocks) CheckState.WARN else CheckState.OK,
|
||||
stringResource(R.string.setup_limit),
|
||||
@@ -243,6 +252,7 @@ internal fun SetupPanel(
|
||||
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
|
||||
else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus)
|
||||
},
|
||||
divider = divider && !showLimitDetails,
|
||||
) {
|
||||
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
|
||||
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
|
||||
@@ -271,6 +281,9 @@ internal fun SetupPanel(
|
||||
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
|
||||
}
|
||||
}
|
||||
if (divider && showLimitDetails) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
val accountCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
CheckRow(
|
||||
if (signedIn) CheckState.OK else CheckState.WARN,
|
||||
stringResource(R.string.setup_account),
|
||||
@@ -281,11 +294,34 @@ internal fun SetupPanel(
|
||||
else -> stringResource(R.string.setup_signed_in, it)
|
||||
}
|
||||
} ?: stringResource(R.string.setup_sign_in),
|
||||
divider = false,
|
||||
divider = divider,
|
||||
)
|
||||
}
|
||||
SectionTitle(stringResource(R.string.setup_tools), null)
|
||||
ToolGrid(s, a)
|
||||
val all = listOf(gpuCheck, runtimeCheck, limitCheck, accountCheck)
|
||||
// Everything passes: one line says so, and Details opens the checks in place.
|
||||
ReadinessCard(
|
||||
passing, checks.size,
|
||||
listOfNotNull(
|
||||
gpu.displayName(ctx),
|
||||
s.installed?.let { stringResource(R.string.setup_ready_runtime, it) },
|
||||
stringResource(R.string.setup_ready_limit_off).takeIf { !limitBlocks },
|
||||
s.offlineAccount?.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.setup_ready_signed_in, it) },
|
||||
).joinToString(" · "),
|
||||
open = showChecks, onDetails = { showChecks = !showChecks },
|
||||
) {
|
||||
val shownChecks = all.filterIndexed { i, _ -> checks[i] }
|
||||
shownChecks.forEachIndexed { i, check -> check(i < shownChecks.lastIndex) }
|
||||
}
|
||||
// Each check that warns is its own card, its Fix it as it always was.
|
||||
if (passing < checks.size) {
|
||||
SectionTitle(stringResource(R.string.setup_needs_you), null)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
all.forEachIndexed { i, check ->
|
||||
if (!checks[i]) Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, AttentionAmber.copy(alpha = 0.5f), Shape14)) { check(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) }
|
||||
}
|
||||
1 -> {
|
||||
val controller = s.controller
|
||||
@@ -295,110 +331,80 @@ internal fun SetupPanel(
|
||||
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
|
||||
}
|
||||
2 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_session)) {
|
||||
ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
|
||||
Box {
|
||||
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
|
||||
FrameGenMenu(s, a, host)
|
||||
val look: @Composable () -> Unit = {
|
||||
SettingsGroup(stringResource(R.string.setup_group_look)) {
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
|
||||
Themes.all.forEachIndexed { index, theme ->
|
||||
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTheme(theme.id)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
|
||||
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
|
||||
percent to stringResource(
|
||||
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
|
||||
else R.string.ctrl_percent, percent,
|
||||
)
|
||||
}, s.appScale, onPick = a.onAppScale,
|
||||
)
|
||||
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
|
||||
s.animationsEnabled, onChange = a.onAnimationsEnabled)
|
||||
LanguageRow(host, s.language, a.onLanguage)
|
||||
ToggleRow(host, "show-stores", stringResource(R.string.setup_stores), null, s.gameStoresEnabled, onChange = a.onGameStoresEnabled)
|
||||
}
|
||||
}
|
||||
val device: @Composable () -> Unit = {
|
||||
SettingsGroup(stringResource(R.string.setup_group_device)) {
|
||||
ToggleRow(
|
||||
host, "home-screen", stringResource(R.string.setup_home),
|
||||
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
if (s.homeScreenEnabled) {
|
||||
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_orientation), null,
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.orientation, onPick = a.onOrientation,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
|
||||
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
|
||||
s.launcherFullscreen,
|
||||
) { a.onLauncherFullscreen(it) }
|
||||
}
|
||||
}
|
||||
val art: @Composable () -> Unit = {
|
||||
SettingsGroup(stringResource(R.string.setup_group_art)) {
|
||||
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
|
||||
ActionRow(
|
||||
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
|
||||
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Two columns on a wide page: how it looks; this device and the library's art.
|
||||
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { look() }
|
||||
Column(Modifier.weight(1f)) { device(); art() }
|
||||
} else { look(); device(); art() }
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
|
||||
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
|
||||
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
|
||||
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
|
||||
ToggleRow(
|
||||
host, "offline", stringResource(R.string.setup_offline),
|
||||
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
|
||||
s.offline, enabled = s.offlineAccount != null,
|
||||
) { a.onOffline() }
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_launcher)) {
|
||||
LanguageRow(host, s.language, a.onLanguage)
|
||||
ChoiceRow(
|
||||
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
|
||||
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
|
||||
percent to stringResource(
|
||||
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
|
||||
else R.string.ctrl_percent, percent,
|
||||
)
|
||||
}, s.appScale, onPick = a.onAppScale,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
|
||||
Themes.all.forEachIndexed { index, theme ->
|
||||
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTheme(theme.id)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToggleRow(
|
||||
host, "home-screen", stringResource(R.string.setup_home),
|
||||
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_orientation), null,
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.orientation, onPick = a.onOrientation,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
|
||||
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
|
||||
s.launcherFullscreen,
|
||||
) { a.onLauncherFullscreen(it) }
|
||||
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
|
||||
s.animationsEnabled, onChange = a.onAnimationsEnabled)
|
||||
if (s.homeScreenEnabled) {
|
||||
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_linux_apps)) {
|
||||
ToggleRow(
|
||||
host, "store-enabled", stringResource(R.string.setup_store),
|
||||
if (s.storeEnabled) stringResource(R.string.setup_store_on)
|
||||
else stringResource(R.string.setup_store_off),
|
||||
s.storeEnabled,
|
||||
) { a.onStoreEnabled(it) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.content_games)) {
|
||||
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
|
||||
ActionRow(
|
||||
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
|
||||
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_stores)) {
|
||||
ToggleRow(
|
||||
host, "stores-enabled", stringResource(R.string.setup_stores_show),
|
||||
null, s.gameStoresEnabled,
|
||||
) { a.onGameStoresEnabled(it) }
|
||||
// The same rows as the Stores page's own cog, for whoever looks here first.
|
||||
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
|
||||
SegmentedTabs(
|
||||
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
|
||||
s.storesOpenTab,
|
||||
) { a.onStoresOpenTab(it) }
|
||||
}
|
||||
ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) }
|
||||
SettingsRow(stringResource(R.string.setup_stores_speed), null) {
|
||||
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
|
||||
}
|
||||
StoresEngineRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,50 +413,43 @@ internal fun SetupPanel(
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
|
||||
/**
|
||||
* The checks as one card while they all pass: a ring with how many pass, "Ready to play" and one
|
||||
* line of what that means, and a Details chip that opens the checks themselves in place ([checks]).
|
||||
*/
|
||||
@Composable
|
||||
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
|
||||
val columns = if (LocalNarrowPane.current) 2 else 4
|
||||
val tools = listOf(
|
||||
ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles),
|
||||
ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons),
|
||||
ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance),
|
||||
ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
for (row in tools.chunked(columns)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun ToolCard(t: ToolSpec, modifier: Modifier) {
|
||||
private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale")
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier.paneItem("tool:${t.title}")
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
|
||||
.controllerConfirm(onClick = t.onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||
Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
val ready = passing == total
|
||||
Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
val tint = if (ready) pal.good else AttentionAmber
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp)) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
progress = { passing / total.toFloat().coerceAtLeast(1f) }, strokeWidth = 4.dp,
|
||||
color = tint, trackColor = tint.copy(alpha = 0.18f), modifier = Modifier.size(52.dp),
|
||||
)
|
||||
Text("$passing/$total", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
|
||||
fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
)
|
||||
if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (passing > 0) SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true, onClick = onDetails)
|
||||
}
|
||||
AnimatedVisibility(open, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
checks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,14 +471,19 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
|
||||
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
|
||||
val wincompKey = game?.let {
|
||||
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
|
||||
else it.appId.takeIf { id -> id > 0 }?.toString()
|
||||
}
|
||||
val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey
|
||||
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
|
||||
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
|
||||
val cards = buildList<@Composable (Modifier) -> Unit> {
|
||||
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
|
||||
add { m ->
|
||||
val back = stringResource(R.string.hop_from_games)
|
||||
// Components is its own section: the card hops there, and B on it comes back here.
|
||||
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
|
||||
val at = Hops.sources["card:components"]
|
||||
if (at == null) a.onComponents(true)
|
||||
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
|
||||
}
|
||||
}
|
||||
add { m ->
|
||||
Box(m) {
|
||||
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
|
||||
@@ -491,8 +495,8 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
|
||||
// An Epic game's own launch choices, kept in its sidecar so a launch from the Steam client
|
||||
// honours them too (droiddeck-store-launch reads the same file).
|
||||
// Epic's launch choices; cloud saves live in the game's Cloud saves view, so a GOG game has no card.
|
||||
val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC }
|
||||
// Store launch choices; cloud saves have their own view.
|
||||
val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC || it == com.droiddeck.launcher.stores.Store.GOG }
|
||||
if (cardStore != null && game?.gameFiles != null) add { m ->
|
||||
Box(m) {
|
||||
StoreLaunchCard(host, game.gameFiles!!, cardStore)
|
||||
@@ -533,7 +537,14 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
|
||||
val options = sidecar?.epic ?: com.droiddeck.launcher.stores.EpicOptions()
|
||||
val epic = store == com.droiddeck.launcher.stores.Store.EPIC
|
||||
val key = "store-" + store.id
|
||||
val value = buildList {
|
||||
val value = if (!epic) stringResource(when {
|
||||
!com.droiddeck.launcher.stores.gog.GogComet.enabled(appContext) || sidecar?.achievements == false -> R.string.gog_achievements_off
|
||||
com.droiddeck.launcher.stores.gog.GogComet.status == "sign-in-needed" -> R.string.gog_achievements_signin
|
||||
com.droiddeck.launcher.stores.gog.GogComet.status in listOf("service-unavailable", "credentials-unavailable", "not-installed") -> R.string.gog_achievements_unavailable
|
||||
com.droiddeck.launcher.stores.gog.GogComet.status == "uploaded" -> R.string.gog_achievements_uploaded
|
||||
com.droiddeck.launcher.stores.gog.GogComet.status == "pending-upload" || com.droiddeck.launcher.stores.gog.GogComet.status == "unlocked-locally" -> R.string.gog_achievements_pending
|
||||
else -> R.string.gog_achievements_on
|
||||
}) else buildList {
|
||||
if (epic && options.offline) add(stringResource(R.string.epic_card_offline))
|
||||
else if (epic && options.eos) add(stringResource(R.string.epic_card_eos))
|
||||
}.ifEmpty { listOf(stringResource(R.string.epic_card_none)) }.joinToString(" · ")
|
||||
@@ -552,6 +563,16 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
|
||||
host.open = if (host.open == key) null else key
|
||||
}
|
||||
AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = store.shortLabel) { first ->
|
||||
if (!epic) {
|
||||
MenuItem(stringResource(R.string.gog_achievements), checked = sidecar?.achievements != false, focusRequester = first,
|
||||
detail = if (!com.droiddeck.launcher.stores.gog.GogComet.enabled(appContext)) stringResource(R.string.gog_achievements_enable_store) else null) {
|
||||
val on = sidecar?.achievements == false
|
||||
Thread({
|
||||
val written = runCatching { com.droiddeck.launcher.stores.StoreGameSidecar.updateAchievements(folder, on) }.getOrNull()
|
||||
com.droiddeck.launcher.stores.StoresState.post { sidecar = written ?: sidecar }
|
||||
}, "gog-game-achievements").start()
|
||||
}
|
||||
}
|
||||
if (epic) {
|
||||
MenuItem(stringResource(R.string.epic_eos), checked = options.eos, focusRequester = first) { set { it.copy(eos = !it.eos) } }
|
||||
MenuItem(stringResource(R.string.epic_offline), checked = options.offline) { set { it.copy(offline = !it.offline) } }
|
||||
|
||||
@@ -52,7 +52,11 @@ import kotlinx.coroutines.withContext
|
||||
// dialog, with each variable's actions in a menu off its value as everywhere else.
|
||||
|
||||
@Composable
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
|
||||
fun GameEnvironmentRow(
|
||||
modifier: Modifier = Modifier,
|
||||
showHint: Boolean = true,
|
||||
initialScope: String = "",
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
|
||||
// would otherwise give the pad nothing to move from.
|
||||
@@ -64,7 +68,7 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true)
|
||||
open = true
|
||||
}
|
||||
}
|
||||
if (open) GameEnvironmentEditor(byPad) { open = false }
|
||||
if (open) GameEnvironmentEditor(byPad, initialScope) { open = false }
|
||||
}
|
||||
|
||||
/** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */
|
||||
@@ -81,19 +85,19 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
|
||||
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
|
||||
var scope by remember { mutableStateOf("") }
|
||||
var scope by remember { mutableStateOf(initialScope) }
|
||||
var otherId by remember { mutableStateOf<String?>(null) }
|
||||
var menu by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
var editing by remember { mutableStateOf<EnvironmentVariableEdit?>(null) }
|
||||
val preset = SessionPrefs.fexPreset(context)
|
||||
val forceSsbs = SessionPrefs.forceSsbs(context)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
@@ -168,7 +172,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
VariableRow(
|
||||
name, value, inherited = !own.containsKey(name), open = menu == "var:$name", enabled = !busy,
|
||||
onToggle = { menu = if (menu == "var:$name") null else "var:$name" }, onDismiss = { if (menu == "var:$name") menu = null },
|
||||
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = name to (value ?: "") },
|
||||
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, name, value ?: "") },
|
||||
// A value set here or inherited can be switched off for this profile; one set here can go back to inheriting.
|
||||
onUnset = if (value != null) ({ menu = null; save(current.withEntries(scope, own + (name to null))) }) else null,
|
||||
onRestore = if (own.containsKey(name)) ({ menu = null; save(current.withEntries(scope, own - name)) }) else null,
|
||||
@@ -185,9 +189,9 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
SecondaryButton(stringResource(R.string.game_env_add), enabled = !busy) { menu = if (menu == "add") null else "add" }
|
||||
AnchoredMenu(menu == "add", onDismiss = { if (menu == "add") menu = null }, title = stringResource(R.string.game_env_add)) { first ->
|
||||
for ((i, option) in GameEnvironmentOptions.all.withIndex()) MenuItem(option.name, checked = false, focusRequester = if (i == 0) first else null) {
|
||||
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = option.name to option.value
|
||||
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, option.name, option.value)
|
||||
}
|
||||
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = "" to "" }
|
||||
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, "", "") }
|
||||
}
|
||||
}
|
||||
if (current != null && current.entries(scope).isNotEmpty()) FocusText(stringResource(R.string.game_env_reset), LocalPalette.current.signal) {
|
||||
@@ -197,19 +201,21 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
PrimaryButton(stringResource(R.string.game_env_done), enabled = !busy, onClick = close)
|
||||
}
|
||||
}
|
||||
editing?.let { (name, value) ->
|
||||
VariableDialog(name, value, profileName, editByPad, onDismiss = { editing = null }) { key, content ->
|
||||
editing?.let { edit ->
|
||||
VariableDialog(edit.name, edit.value, edit.profileName, editByPad, onDismiss = { editing = null }) { key, content ->
|
||||
config?.let {
|
||||
val entries = it.entries(scope).toMutableMap()
|
||||
if (name.isNotEmpty() && name != key) entries[name] = null
|
||||
val entries = it.entries(edit.scope).toMutableMap()
|
||||
if (edit.name.isNotEmpty() && edit.name != key) entries[edit.name] = null
|
||||
entries[key] = content
|
||||
save(it.withEntries(scope, entries))
|
||||
save(it.withEntries(edit.scope, entries))
|
||||
}
|
||||
editing = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class EnvironmentVariableEdit(val scope: String, val profileName: String, val name: String, val value: String)
|
||||
|
||||
/** The panel the profile and the variables sit in, as a settings group does. */
|
||||
@Composable
|
||||
private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) =
|
||||
|
||||
@@ -57,6 +57,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
|
||||
class PairRow(
|
||||
@@ -119,155 +123,38 @@ class GpuDriversActions(
|
||||
)
|
||||
|
||||
/**
|
||||
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
|
||||
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
|
||||
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
|
||||
* two lists each on their own, and imports, are under Advanced.
|
||||
* Everything about the chip - SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the
|
||||
* drivers in use - gathered once, off the main thread, with a button that copies it for a bug
|
||||
* report. Stepped out of the Components page's device chip; [first] is where a pad starts.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
|
||||
internal fun DeviceDetails(first: FocusRequester) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var showAll by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
DeviceCard(s)
|
||||
if (!s.unsupported) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) }
|
||||
ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
|
||||
}
|
||||
if (s.auto) AutoCard(s)
|
||||
else {
|
||||
val shown = s.pairs.filter { it.suits || showAll }
|
||||
if (shown.isEmpty()) Text(
|
||||
if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
|
||||
if (s.pairs.any { !it.suits }) FocusText(
|
||||
if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal,
|
||||
) { showAll = !showAll }
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.gpu_advanced)) {
|
||||
SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) {
|
||||
DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) {
|
||||
DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
|
||||
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
|
||||
*/
|
||||
@Composable
|
||||
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
|
||||
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
for (row in rows) DropdownMenuItem(
|
||||
leadingIcon = { Radio(row.id == selected) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
|
||||
)
|
||||
HorizontalDivider(color = pal.line)
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip -
|
||||
* SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once,
|
||||
* off the main thread, with a button that copies it for a bug report.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val tint = when {
|
||||
s.unsupported -> pal.error
|
||||
s.supported -> pal.good
|
||||
else -> AttentionAmber
|
||||
}
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
var details by remember { mutableStateOf<List<DeviceInfo.Section>?>(null) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(open) {
|
||||
if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) }
|
||||
}
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val toggle = { open = !open; copied = false }
|
||||
LaunchedEffect(Unit) { details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } }
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clip(Shape14).background(colors.surface)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("gpu-device")
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
|
||||
.controllerConfirm(onClick = toggle)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
|
||||
// needs: a long one (in some languages) would squeeze the name to a letter a line.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(
|
||||
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
|
||||
contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show),
|
||||
tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(open) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) {
|
||||
HorizontalDivider(color = pal.line)
|
||||
val sections = details
|
||||
if (sections == null) {
|
||||
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
for (section in sections) DeviceInfoSection(section)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.device_info_copy), compact = true) {
|
||||
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
val sections = details
|
||||
if (sections == null) {
|
||||
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.device_info_copy), compact = true, modifier = Modifier.focusRequester(first)) {
|
||||
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
for (section in sections) DeviceInfoSection(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,115 +175,9 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) {
|
||||
Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
for ((label, value) in section.rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp))
|
||||
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(150.dp))
|
||||
Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What Auto has set and what it is doing. */
|
||||
@Composable
|
||||
private fun AutoCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val active = s.activePair
|
||||
val recommended = s.pairs.firstOrNull { it.recommended }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Text(
|
||||
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin),
|
||||
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
)
|
||||
Text(
|
||||
if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair)
|
||||
else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (s.busy != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (s.percent.coerceAtLeast(0)) / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
// Selection is the radio; the ring is only ever focus.
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
|
||||
.clip(Shape14).background(colors.surface).background(fill)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Radio(selected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val usable = enabled && p.complete && !p.active
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
|
||||
.clip(Shape12).background(colors.surface).background(fill)
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(enabled = usable, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Radio(p.active)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (p.recommended) Text(
|
||||
stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
|
||||
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
when {
|
||||
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
|
||||
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
|
||||
}
|
||||
p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
!p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Radio(selected: Boolean) {
|
||||
val pal = LocalPalette.current
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
|
||||
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
// A link from a control in one section to a page in another (the Play sheet's Graphics drivers,
|
||||
// a game's Components card): the control squashes into a dot that flies to the destination's rail
|
||||
// item, the rail's selection glides there as it passes the top of its arc, and the page floods
|
||||
// out of the rail item once it lands. B on that page flies the dot back and puts the source
|
||||
// surface back as it was, focus on the control that asked.
|
||||
|
||||
/**
|
||||
* Where a hop came from: the rail item that was showing ([rail]), the surface on it (a sheet, the
|
||||
* Games page) and its tab, the control's key and its root bounds, and the words for the way back.
|
||||
* [page] is the page key the hop opened, so leaving it any other way forgets the way back.
|
||||
*/
|
||||
internal class HopOrigin(
|
||||
val rail: String,
|
||||
val surface: String,
|
||||
val tab: String?,
|
||||
val control: String,
|
||||
val bounds: Rect,
|
||||
val back: String,
|
||||
val dest: String,
|
||||
val page: String,
|
||||
)
|
||||
|
||||
/** The surfaces a hop can start from and come back to. */
|
||||
internal const val HOP_SURFACE_GAMES = "games"
|
||||
internal const val HOP_SURFACE_DESKTOP = "desktop"
|
||||
internal const val HOP_SURFACE_SHEET = "sheet"
|
||||
|
||||
internal object Hops {
|
||||
/** The hop the page on screen was reached by, while B can still undo it. */
|
||||
var origin by mutableStateOf<HopOrigin?>(null)
|
||||
|
||||
/** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */
|
||||
var sinking by mutableStateOf<String?>(null)
|
||||
|
||||
/** Set while a hop's dot is flying: the rail item its selection has already glided to. */
|
||||
var railPreview by mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* Set as a page leaves through a hop (either way), so the pane sinks it rather than draining
|
||||
* it; the pane clears it once the change has started.
|
||||
*/
|
||||
var linking by mutableStateOf(false)
|
||||
|
||||
/** A surface to put back as it was (a sheet re-lifted on its tab), once, and the control to focus. */
|
||||
var restore by mutableStateOf<HopOrigin?>(null)
|
||||
|
||||
/** Per rail item, bumped when a dot lands on it: the item pops. */
|
||||
val railLanded = mutableStateMapOf<String, Int>()
|
||||
|
||||
/** Bumped when a hop's page has flooded in: its target (a layer card) sends out one ring. */
|
||||
var arrived by mutableIntStateOf(0)
|
||||
|
||||
/** Where each rail item sits, root px. */
|
||||
val railBounds = HashMap<String, Rect>()
|
||||
|
||||
/** Where each control that can start a hop sits, root px. */
|
||||
val sources = HashMap<String, Rect>()
|
||||
|
||||
/**
|
||||
* Hops from the control at [origin].bounds to [origin].dest: [go] opens the page there once the dot lands.
|
||||
* With animations off there is no dot and no flood, only the page.
|
||||
*/
|
||||
fun go(origin: HopOrigin, go: () -> Unit) {
|
||||
val railAt = railBounds[origin.dest]
|
||||
val finish: () -> Unit = {
|
||||
railPreview = null
|
||||
this.origin = origin
|
||||
if (railAt != null && Motion.scale != 0f) PageOrigin.mark(Origin(railAt, 14f * density, owner = origin.dest))
|
||||
linking = true
|
||||
go()
|
||||
railLanded[origin.dest] = (railLanded[origin.dest] ?: 0) + 1
|
||||
arrived++
|
||||
}
|
||||
if (railAt == null || Motion.scale == 0f) { finish(); return }
|
||||
// The surface the control is on sinks as its dot leaves, and stays down as it goes.
|
||||
sinking = origin.rail
|
||||
Flights.fly(Flight(
|
||||
origin.bounds, to = { railAt.center },
|
||||
onHalf = { railPreview = origin.dest },
|
||||
onLand = finish,
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* B on a page reached by a hop: the page sinks, the source surface comes back as it was, and
|
||||
* the dot flies home from the rail item to the control. False when there is no hop to undo.
|
||||
*/
|
||||
fun back(close: () -> Unit): Boolean {
|
||||
val o = origin ?: return false
|
||||
origin = null
|
||||
sinking = null
|
||||
val railAt = railBounds[o.dest]
|
||||
railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null
|
||||
restore = o
|
||||
linking = true
|
||||
close()
|
||||
if (railAt == null || Motion.scale == 0f) { railPreview = null; return true }
|
||||
Flights.fly(Flight(
|
||||
railAt, to = { (sources[o.control] ?: o.bounds).center },
|
||||
onHalf = { railPreview = null },
|
||||
))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The page the hop opened has gone some other way: there is no way back to keep. */
|
||||
fun forget(pageKey: String?) {
|
||||
val o = origin ?: return
|
||||
if (pageKey != o.page) { origin = null; sinking = null }
|
||||
}
|
||||
|
||||
/** Screen density, for the rail item's corner; set by the front end. */
|
||||
var density = 1f
|
||||
}
|
||||
|
||||
/** Shows a rail section directly (no page closing, nothing forgotten): what a hop's way back calls. */
|
||||
internal val LocalShowSection = androidx.compose.runtime.staticCompositionLocalOf<(String) -> Unit> { {} }
|
||||
|
||||
/** Remembers where this control sits, so a hop from it can fly from it and back to it. */
|
||||
internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() }
|
||||
|
||||
/**
|
||||
* "B · back to <source>" at the right of a page's header while it was reached by a hop; a tap is B.
|
||||
* Nothing when it was not.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HopBackChip(onBack: () -> Unit) {
|
||||
val o = Hops.origin ?: return
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
Text(
|
||||
stringResource(R.string.hop_back, o.back), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (hot) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier.heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
|
||||
.background(pal.signal.copy(alpha = if (hot) 0.16f else 0.08f))
|
||||
.glideBorder(hot, shape, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onBack)
|
||||
.controllerConfirm(onClick = onBack)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import kotlinx.coroutines.Job
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Play's settings, lifted out from under Play instead of opened on a page of their own. At rest a
|
||||
// lip tucks out under Play and says what it will do. Down (or a pull up, or a tap on the lip)
|
||||
// stretches the lip, the wall sinks back, and a sheet rises from the pane's foot with Play riding
|
||||
// its top edge as the handle - StepOut's move, with the pane for a box. B, Up from the chips or a
|
||||
// tap above folds it away; A on Play launches straight from it, the sheet folding into the pill
|
||||
// before the launch flood. Open desktop does the same with the desktop's settings.
|
||||
|
||||
/** The sheets' tabs (their chips), by id. */
|
||||
internal object SheetTab {
|
||||
const val DISPLAY = "display"
|
||||
const val CONTROLS = "controls"
|
||||
const val AUDIO = "audio"
|
||||
const val SESSION = "session"
|
||||
const val LIBRARY = "library"
|
||||
const val STEAM = "steam"
|
||||
const val FILES = "files"
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the open sheet's top edge sits in a pane [paneH] tall (px), for a button [buttonH] tall
|
||||
* riding it: 60dp down a 360dp pane, the same share of any other, and never so high that the
|
||||
* button's top comes closer than [minAbove] to the pane's. [ride] is how far below the edge the
|
||||
* button's centre sits.
|
||||
*/
|
||||
internal fun sheetTop(paneH: Float, buttonH: Float, minAbove: Float, ride: Float): Float =
|
||||
maxOf(paneH * 60f / 360f, minAbove + buttonH / 2f - ride)
|
||||
|
||||
/** A finger's hold on the sheet: where it started (0 rest, 1 open), where it is, whether it has it. */
|
||||
private class SheetDrag {
|
||||
var from = 0f
|
||||
var progress = 0f
|
||||
var engaged = false
|
||||
}
|
||||
|
||||
/** One piece of the sheet's rows fades and rises in on its own beat (a settings group asks for its index). */
|
||||
internal class SheetBeat(private val pieces: List<Animatable<Float, AnimationVector1D>>) {
|
||||
private var next = 1
|
||||
fun take(): Int = next++
|
||||
fun value(i: Int): Float = pieces[i.coerceIn(0, pieces.lastIndex)].value
|
||||
}
|
||||
|
||||
/** The sheet whose rows are being laid out, so each settings group can take its beat. */
|
||||
internal val LocalSheetBeat = staticCompositionLocalOf<SheetBeat?> { null }
|
||||
|
||||
/** A settings group's piece of the sheet's entrance; nothing outside a sheet. */
|
||||
@Composable
|
||||
internal fun Modifier.sheetPiece(): Modifier {
|
||||
val beat = LocalSheetBeat.current ?: return this
|
||||
val i = remember { beat.take() }
|
||||
return graphicsLayer {
|
||||
val v = beat.value(i)
|
||||
alpha = v
|
||||
translationY = (1f - v) * 6.dp.toPx()
|
||||
}
|
||||
}
|
||||
|
||||
private val SheetInset = 12.dp
|
||||
private val SheetCorner = 18.dp
|
||||
private val ChipsTop = 14.dp
|
||||
private val RowsTop = 54.dp
|
||||
private const val PIECES = 12
|
||||
|
||||
/**
|
||||
* A hero with one big button and the sheet that lifts out from under it. [key] names the sheet
|
||||
* (a hop comes back to it, the lip remembers what it said); [tabs] are its chips (id to label) and
|
||||
* [rows] what each holds. [background] sinks and [words] fade as it opens. [button] draws the
|
||||
* button itself with the click it should make; [onPrimary] is what that click does once the
|
||||
* sheet is out of the way. [onOpen] reads the settings the rows show.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun LiftHero(
|
||||
key: String,
|
||||
tabs: List<Pair<String, String>>,
|
||||
onOpen: () -> Unit,
|
||||
onPrimary: () -> Unit,
|
||||
background: @Composable () -> Unit,
|
||||
/**
|
||||
* Drawn over [background] at the pane's own size: the wall's fades, which must not sink with
|
||||
* it. Given how far the sheet is up (0 to 1), for anything there that should give way to it.
|
||||
*/
|
||||
scrim: @Composable (lifted: () -> Float) -> Unit = {},
|
||||
words: @Composable () -> Unit,
|
||||
/**
|
||||
* Buttons beside the big one (Desktop's Flathub and +). They stay where they are and fade as
|
||||
* the sheet lifts: only the big button rides it.
|
||||
*/
|
||||
beside: @Composable () -> Unit = {},
|
||||
button: @Composable (onClick: () -> Unit) -> Unit,
|
||||
rows: @Composable (tab: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Down on the button lifts the sheet (Play). False: Down goes to the lip, which A lifts, and Down
|
||||
* from the lip goes on past it to what is under the hero (Desktop's shelves).
|
||||
*/
|
||||
downOpens: Boolean = true,
|
||||
/** Told when the sheet starts to lift (true) and once it has gone (false). */
|
||||
onOpenChange: (Boolean) -> Unit = {},
|
||||
start: Dp = 44.dp,
|
||||
bottom: Dp = 44.dp,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val inputMode = LocalInputModeManager.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val ff = LocalFrontFocus.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
// Held as states too, so the sheet's focus rule reads what they are now, not at the last composition.
|
||||
val openState = remember { mutableStateOf(false) }
|
||||
var open by openState
|
||||
var shown by remember { mutableStateOf(false) }
|
||||
val launchingState = remember { mutableStateOf(false) }
|
||||
var launching by launchingState
|
||||
var tab by remember { mutableStateOf(0) }
|
||||
var travel by remember { mutableStateOf(1) }
|
||||
// The control to focus once a sheet put back by a hop's B has lifted, instead of the first chip.
|
||||
var focusOnLift by remember { mutableStateOf<String?>(null) }
|
||||
// Set as the sheet closes and Play lands: the lip may come back before the sheet has quite gone.
|
||||
var lipBack by remember { mutableStateOf(false) }
|
||||
// Set as a finger's drag lets go: the sheet is already where it is going.
|
||||
var dragSettled by remember { mutableStateOf(false) }
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var dragJob by remember { mutableStateOf<Job?>(null) }
|
||||
val onOpenNow = rememberUpdatedState(onOpen)
|
||||
val pull = remember { Animatable(0f) }
|
||||
val sink = remember { Animatable(0f) }
|
||||
val lift = remember { Animatable(0f) }
|
||||
val ride = remember { Animatable(0f) }
|
||||
val hot = remember { Animatable(1f) }
|
||||
val lipShow = remember { Animatable(0f) }
|
||||
val lipGone = remember { Animatable(0f) }
|
||||
val fold = remember { Animatable(0f) }
|
||||
val foldTop = remember { Animatable(0f) }
|
||||
val pieces = remember { List(PIECES) { Animatable(0f) } }
|
||||
val chipFocus = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
|
||||
val playSrc = remember { MutableInteractionSource() }
|
||||
val playHovered by playSrc.collectIsHoveredAsState()
|
||||
var playFocused by remember { mutableStateOf(false) }
|
||||
// Set as a pad leaves the sheet to the left for Play, which folds it once Play has focus.
|
||||
val leftForPlay = remember { booleanArrayOf(false) }
|
||||
var heroAt by remember { mutableStateOf(Offset.Zero) }
|
||||
var rest by remember { mutableStateOf<Rect?>(null) }
|
||||
fun focusPlay() { if (ff != null && inputMode.inputMode == InputMode.Keyboard) runCatching { ff.primary.requestFocus() } }
|
||||
fun close() {
|
||||
if (!open) return
|
||||
// Focus leaves the rows before they go; the sheet lets it go once it is closing.
|
||||
open = false
|
||||
focusPlay()
|
||||
}
|
||||
// Back from a hop the sheet started (or a page it opened): put it back as it was.
|
||||
LaunchedEffect(Unit) {
|
||||
val r = Hops.restore ?: return@LaunchedEffect
|
||||
if (r.surface != HOP_SURFACE_SHEET || r.rail != key) return@LaunchedEffect
|
||||
Hops.restore = null
|
||||
tab = tabs.indexOfFirst { it.first == r.tab }.coerceAtLeast(0)
|
||||
focusOnLift = r.control
|
||||
open = true
|
||||
}
|
||||
// Launched: the session covers the screen, and the sheet is put away under it.
|
||||
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = androidx.lifecycle.LifecycleEventObserver { _, e ->
|
||||
if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (open || shown)) {
|
||||
open = false; shown = false; launching = false
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
LaunchedEffect(open, launching) {
|
||||
if (!dragSettled) { dragJob?.cancel(); dragJob = null }
|
||||
if (dragSettled) {
|
||||
// A finger brought it here and has already animated it: only what opening needs.
|
||||
dragSettled = false
|
||||
if (open) {
|
||||
if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { chipFocus[tab].requestFocus() } }
|
||||
} else focusPlay()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (open && !launching) {
|
||||
lipBack = false
|
||||
shown = true
|
||||
onOpen()
|
||||
fold.snapTo(0f); foldTop.snapTo(0f)
|
||||
coroutineScope {
|
||||
// Pull: the lip stretches down as the wall sinks back and dims.
|
||||
launch { pull.animateTo(24f, Motion.sp(0.7f, 380f)) }
|
||||
launch { sink.animateTo(1f, Motion.tw(260)) }
|
||||
// Lift: the sheet rises with Play riding its top edge; the lip lets go.
|
||||
launch {
|
||||
delay(Motion.ms(120).toLong())
|
||||
launch { lipGone.animateTo(1f, Motion.tw(140)) }
|
||||
launch { pull.animateTo(0f, Motion.sp(1f, 500f)) }
|
||||
launch { lift.animateTo(1f, Motion.sp(0.7f, 380f)) }
|
||||
launch { ride.animateTo(1f, Motion.sp(0.7f, 380f)) }
|
||||
launch { hot.snapTo(1f); delay(Motion.ms(100).toLong()); hot.animateTo(0f, Motion.tw(200)) }
|
||||
}
|
||||
// Each piece on its beat from the Lift, as the reel has it: 260ms + 55ms a piece after it starts.
|
||||
pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } }
|
||||
if (inputMode.inputMode == InputMode.Keyboard) launch {
|
||||
val control = focusOnLift
|
||||
focusOnLift = null
|
||||
delay(Motion.ms(if (control != null) 420 else 300).toLong())
|
||||
withFrameNanos { }
|
||||
val target = control?.let { ff?.items?.get(it) } ?: chipFocus[tab]
|
||||
runCatching { target.requestFocus() }
|
||||
}
|
||||
}
|
||||
} else if (launching) {
|
||||
// Play from the sheet: the rows go, the sheet folds into the pill, then the launch.
|
||||
coroutineScope {
|
||||
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
|
||||
launch { delay(Motion.ms(60).toLong()); fold.animateTo(1f, Motion.sp(1f, 420f)) }
|
||||
launch { delay(Motion.ms(160).toLong()); foldTop.animateTo(1f, Motion.sp(1f, 380f)) }
|
||||
}
|
||||
shown = false
|
||||
onPrimary()
|
||||
// The session covers the screen and the sheet is put away under it (ON_STOP). If
|
||||
// nothing started, put it away anyway.
|
||||
delay(3_000)
|
||||
launching = false
|
||||
} else if (shown) {
|
||||
coroutineScope {
|
||||
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
|
||||
launch { delay(Motion.ms(60).toLong()); lift.animateTo(0f, Motion.sp(1f, 380f)) }
|
||||
launch { delay(Motion.ms(100).toLong()); ride.animateTo(0f, Motion.sp(0.55f, 300f)) }
|
||||
launch { sink.animateTo(0f, Motion.tw(300)) }
|
||||
// The lip tucks back under Play as it lands, not once every spring has settled.
|
||||
launch { delay(Motion.ms(420).toLong()); lipGone.snapTo(0f); lipBack = true }
|
||||
}
|
||||
shown = false
|
||||
lipBack = false
|
||||
lipGone.snapTo(0f)
|
||||
hot.snapTo(1f)
|
||||
} else {
|
||||
// Snapped shut under a session: everything back at rest, no animation.
|
||||
listOf(pull, sink, lift, ride, lipGone, fold, foldTop).forEach { it.snapTo(0f) }
|
||||
pieces.forEach { it.snapTo(0f) }
|
||||
hot.snapTo(1f)
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = open && !launching) { close() }
|
||||
// The ring keeps out of the lift and the fold, and comes back on whatever has focus once
|
||||
// they are done: Play rides the sheet on a layer, and the ring hung where Play had been.
|
||||
if (ride.isRunning || (shown && !open)) VeilRing()
|
||||
val openNow = open || shown
|
||||
LaunchedEffect(openNow) { onOpenChange(openNow) }
|
||||
|
||||
// The lip: there while Play has a pad or a pointer on it, or always with touch alone.
|
||||
val touchOnly = inputMode.inputMode == InputMode.Touch
|
||||
val lipFocus = remember { FocusRequester() }
|
||||
val settingsLabel = stringResource(R.string.drawer_page_settings)
|
||||
var lipFocused by remember { mutableStateOf(false) }
|
||||
val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused)
|
||||
LaunchedEffect(lipWanted) {
|
||||
if (lipWanted) {
|
||||
// Back from the sheet it comes straight away; otherwise a beat after Play has focus.
|
||||
if (!lipBack) delay(Motion.ms(160).toLong())
|
||||
lipShow.animateTo(1f, Motion.tw(220))
|
||||
} else {
|
||||
delay(Motion.ms(140).toLong())
|
||||
lipShow.animateTo(0f, Motion.tw(220))
|
||||
}
|
||||
}
|
||||
|
||||
// How far the sheet travels, for a finger's drag to follow: set once the hero is measured.
|
||||
val travelPx = remember { floatArrayOf(1f) }
|
||||
val drag = remember { SheetDrag() }
|
||||
/** The sheet [p] of the way up (0 at rest, 1 open) under a finger. */
|
||||
fun follow(p: Float) {
|
||||
drag.progress = p
|
||||
scope.launch {
|
||||
lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f)
|
||||
lipGone.snapTo(1f)
|
||||
pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) }
|
||||
}
|
||||
}
|
||||
/** A finger takes the sheet where it is: up from rest, or down from open. */
|
||||
fun beginDrag() {
|
||||
dragJob?.cancel(); dragJob = null
|
||||
dragSettled = false
|
||||
drag.engaged = true
|
||||
drag.from = if (open) 1f else 0f
|
||||
drag.progress = drag.from
|
||||
if (!open) {
|
||||
shown = true
|
||||
scope.launch { fold.snapTo(0f); foldTop.snapTo(0f) }
|
||||
onOpenNow.value()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* The finger lets go with [v] px/s (up negative): a flick goes the way it was flicked; a slow
|
||||
* drag stays once it is a third of the way from where it started.
|
||||
*/
|
||||
fun settleDrag(v: Float) {
|
||||
drag.engaged = false
|
||||
val flick = with(density) { 600.dp.toPx() }
|
||||
val opens = when {
|
||||
v < -flick -> true
|
||||
v > flick -> false
|
||||
drag.from > 0.5f -> drag.progress > 0.7f
|
||||
else -> drag.progress > 0.3f
|
||||
}
|
||||
dragSettled = true
|
||||
dragJob?.cancel()
|
||||
dragJob = scope.launch {
|
||||
val to = if (opens) 1f else 0f
|
||||
coroutineScope {
|
||||
launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) }
|
||||
launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) }
|
||||
launch { sink.animateTo(to, Motion.tw(220)) }
|
||||
pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } }
|
||||
}
|
||||
if (!opens) { shown = false; lipGone.snapTo(0f); hot.snapTo(1f); lipBack = false }
|
||||
}
|
||||
if (open != opens) open = opens else dragSettled = false
|
||||
}
|
||||
// Pulled down past the top of the rows, the sheet itself comes down with the finger; up again
|
||||
// before letting go, it goes back up before the rows scroll.
|
||||
val rowsPull = remember {
|
||||
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
|
||||
if (!drag.engaged || available.y >= 0f) return Offset.Zero
|
||||
val before = drag.progress
|
||||
follow((before - available.y / travelPx[0]).coerceAtMost(1f))
|
||||
return Offset(0f, (drag.progress - before) * -travelPx[0])
|
||||
}
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
|
||||
if (available.y <= 0f || source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag || !openState.value || launchingState.value) return Offset.Zero
|
||||
if (!drag.engaged) beginDrag()
|
||||
follow((drag.progress - available.y / travelPx[0]).coerceIn(0f, 1f))
|
||||
return available
|
||||
}
|
||||
override suspend fun onPreFling(available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity {
|
||||
if (!drag.engaged) return androidx.compose.ui.unit.Velocity.Zero
|
||||
settleDrag(available.y)
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft }
|
||||
// A swipe anywhere on the hero moves the sheet under the finger - up from rest, down
|
||||
// when open - and lets go open or shut by how far it went and how fast. A swipe that
|
||||
// starts the wrong way (down at rest) is left alone, for the page under it.
|
||||
.pointerInput(Unit) {
|
||||
val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
|
||||
var decided = false
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false },
|
||||
onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) },
|
||||
onDragCancel = { if (drag.engaged) settleDrag(0f) },
|
||||
) { change, amount ->
|
||||
if (launching) return@detectVerticalDragGestures
|
||||
if (!decided) {
|
||||
decided = true
|
||||
// Up at rest opens; down when open closes. Anything else is not for the sheet.
|
||||
if (if (open) amount > 0f else amount < 0f) beginDrag()
|
||||
}
|
||||
if (!drag.engaged) return@detectVerticalDragGestures
|
||||
change.consume()
|
||||
tracker.addPosition(change.uptimeMillis, change.position)
|
||||
follow((drag.progress - amount / travelPx[0]).coerceIn(0f, 1.04f))
|
||||
}
|
||||
},
|
||||
) {
|
||||
val w = constraints.maxWidth.toFloat()
|
||||
val h = constraints.maxHeight.toFloat()
|
||||
val inset = with(density) { SheetInset.toPx() }
|
||||
val restRect = rest?.translate(-heroAt)
|
||||
val buttonH = restRect?.height ?: with(density) { 56.dp.toPx() }
|
||||
val openTop = sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, with(density) { 3.dp.toPx() })
|
||||
// Play's raised place: its centre 3dp under the sheet's edge, same left inset.
|
||||
val raise = restRect?.let { (openTop + with(density) { 3.dp.toPx() } - buttonH / 2f) - it.top } ?: 0f
|
||||
travelPx[0] = (h - openTop).coerceAtLeast(1f)
|
||||
|
||||
// The wall behind sinks back about the pane's centre.
|
||||
val foot = h
|
||||
Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() }
|
||||
// The fades stay put: scaled with the wall they stopped short of the pane's edges and left
|
||||
// a bright band of unfaded wall down the side and along the top.
|
||||
scrim { sink.value }
|
||||
// The dim; a tap on it (above the sheet) folds the sheet away.
|
||||
if (shown || open) Box(
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer { alpha = sink.value }.background(Color.Black.copy(alpha = 0.42f))
|
||||
.focusProperties { canFocus = false }
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open && !launching) { close() },
|
||||
)
|
||||
|
||||
// The sheet: inset at the sides, rounded at the top, no bottom edge - the pane's foot is its foot.
|
||||
if (shown) {
|
||||
val top = lerp(foot, openTop, lift.value)
|
||||
val sheetLeft = inset
|
||||
val sheetRight = w - inset
|
||||
val path = remember { Path() }
|
||||
val rimPath = remember { Path() }
|
||||
val corner = with(density) { SheetCorner.toPx() }
|
||||
val rim = with(density) { 1.4.dp.toPx() }
|
||||
val pill = restRect?.translate(0f, raise)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val f = fold.value
|
||||
val ft = foldTop.value
|
||||
val l = if (pill != null) lerp(sheetLeft, pill.left, f) else sheetLeft
|
||||
val r = if (pill != null) lerp(sheetRight, pill.right, f) else sheetRight
|
||||
val t = if (pill != null) lerp(top, pill.top, ft) else top
|
||||
val b = if (pill != null) lerp(foot + corner, pill.bottom, ft) else foot + corner
|
||||
val c = if (pill != null) lerp(corner, with(density) { 12.dp.toPx() }, ft) else corner
|
||||
val bc = if (ft > 0f) c else 0f
|
||||
path.reset()
|
||||
path.addRoundRect(RoundRect(l, t, r, b, CornerRadius(c), CornerRadius(c), CornerRadius(bc), CornerRadius(bc)))
|
||||
val fill = lerp(colors.surface, pal.signal, 0.22f * hot.value)
|
||||
drawPath(path, fill)
|
||||
val rimColor = pal.signal.copy(alpha = lerp(0.55f, 0.9f, hot.value))
|
||||
if (ft > 0f) drawPath(path, rimColor, style = Stroke(rim))
|
||||
else {
|
||||
// Sides and top only: the sheet runs on under the pane's foot.
|
||||
rimPath.reset()
|
||||
rimPath.moveTo(l, b)
|
||||
rimPath.lineTo(l, t + c)
|
||||
rimPath.arcTo(Rect(l, t, l + 2 * c, t + 2 * c), 180f, 90f, false)
|
||||
rimPath.lineTo(r - c, t)
|
||||
rimPath.arcTo(Rect(r - 2 * c, t, r, t + 2 * c), -90f, 90f, false)
|
||||
rimPath.lineTo(r, b)
|
||||
drawPath(rimPath, rimColor, style = Stroke(rim))
|
||||
}
|
||||
}
|
||||
// What the sheet holds: the chips to the right of Play, the rows under them.
|
||||
val chipsX = with(density) {
|
||||
if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - sheetLeft
|
||||
}
|
||||
val chipsY = with(density) { if (narrow && pill != null) (pill.bottom - openTop) + 10.dp.toPx() else ChipsTop.toPx() }
|
||||
// The rows start under the chips, which are 44dp to touch: from 54dp they ran under the
|
||||
// chips' feet and, scrolled, covered them.
|
||||
val rowsY = with(density) { maxOf(RowsTop.toPx(), chipsY + 52.dp.toPx()) }
|
||||
val sheetW = sheetRight - sheetLeft
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset(sheetLeft.roundToInt(), top.roundToInt()) }
|
||||
.size(with(density) { sheetW.toDp() }, with(density) { (foot - openTop).coerceAtLeast(0f).toDp() })
|
||||
// Touches on the sheet stay on it; none reach the dim behind.
|
||||
.pointerInput(Unit) { detectTapGestures { } }
|
||||
.controllerBack { close() }
|
||||
.bumpers(
|
||||
// LB / RB: focus goes to the new tab's chip, so a pad on a row (which goes with
|
||||
// its tab) is never left with nothing.
|
||||
onPrevious = { if (tab > 0) { travel = -1; tab--; runCatching { chipFocus[tab].requestFocus() } } },
|
||||
onNext = { if (tab < tabs.lastIndex) { travel = 1; tab++; runCatching { chipFocus[tab].requestFocus() } } },
|
||||
)
|
||||
// A pad stays in the sheet until it folds; Left from it reaches Play, the handle, and folds it.
|
||||
.focusProperties { exit = { dir -> if (!openState.value || launchingState.value) FocusRequester.Default else if (dir == FocusDirection.Left && ff != null) { leftForPlay[0] = true; ff.primary } else FocusRequester.Cancel } }
|
||||
.focusGroup(),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) }
|
||||
// Over the rows, whatever they scroll under them.
|
||||
.zIndex(1f)
|
||||
.width(with(density) { (sheetW - chipsX - 64.dp.toPx()).coerceAtLeast(0f).toDp() })
|
||||
.graphicsLayer { val v = pieces[0].value; alpha = v; translationY = (1f - v) * 6.dp.toPx() }
|
||||
// Up from the chips folds the sheet (a finger drags it down, see the hero's drag).
|
||||
.onPreviewKeyEvent { e ->
|
||||
val k = e.nativeKeyEvent
|
||||
if (k.keyCode == KeyEvent.KEYCODE_DPAD_UP) { if (k.action == KeyEvent.ACTION_DOWN) close(); true } else false
|
||||
},
|
||||
) {
|
||||
SheetChips(tabs.map { it.second }, tab, chipFocus) { i -> travel = if (i > tab) 1 else -1; tab = i }
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset(0, rowsY.roundToInt()) }
|
||||
.nestedScroll(rowsPull)
|
||||
.fillMaxWidth()
|
||||
.height(with(density) { (foot - openTop - rowsY).coerceAtLeast(0f).toDp() }),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
val d = travel
|
||||
val shift = with(density) { 24.dp.roundToPx() }
|
||||
(slideInHorizontally(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240)))
|
||||
.togetherWith(slideOutHorizontally(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240)))
|
||||
},
|
||||
label = "sheetTab",
|
||||
) { t ->
|
||||
val leaving = transition.targetState == androidx.compose.animation.EnterExitState.PostExit
|
||||
val leavingNow = rememberUpdatedState(leaving)
|
||||
CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(pieces) }) {
|
||||
Column(
|
||||
Modifier.fillMaxSize()
|
||||
// Focus never lands in rows on their way out. Same group always, only
|
||||
// its rule changes (canFocus here made the scroll's own group a stop).
|
||||
.focusProperties { enter = { if (leavingNow.value) FocusRequester.Cancel else FocusRequester.Default } }
|
||||
.focusGroup()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
) { rows(tabs[t].first) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val closeSource = remember { MutableInteractionSource() }
|
||||
val closeHot = rememberHot(closeSource)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset((sheetW - 52.dp.toPx()).roundToInt(), chipsY.roundToInt()) }
|
||||
.zIndex(1f).size(44.dp).testTag("sheet-close")
|
||||
.clip(CircleShape).glideBorder(closeHot, CircleShape, pal.signal)
|
||||
.hoverable(closeSource)
|
||||
.clickable(interactionSource = closeSource, indication = LocalIndication.current, enabled = open && !launching, role = Role.Button) { close() }
|
||||
.controllerConfirm(enabled = open && !launching) { close() },
|
||||
) { Icon(Icons.Filled.Close, stringResource(R.string.common_close), tint = colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
// The words and the button, where they always were; the button rides the sheet's edge.
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) {
|
||||
Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() }
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.onGloballyPositioned { rest = it.boundsInRoot() }
|
||||
.graphicsLayer { translationY = raise * ride.value }
|
||||
.onFocusChanged {
|
||||
// Left out of the sheet (from the chips or a row) folds it, as Up from the chips does.
|
||||
if (it.hasFocus && leftForPlay[0]) { leftForPlay[0] = false; if (openState.value && !launchingState.value) close() }
|
||||
playFocused = it.hasFocus
|
||||
}
|
||||
.hoverable(playSrc)
|
||||
.onPreviewKeyEvent { e ->
|
||||
val k = e.nativeKeyEvent
|
||||
if (k.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) false
|
||||
else {
|
||||
if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) {
|
||||
if (open) runCatching { chipFocus[tab].requestFocus() }
|
||||
else if (downOpens) open = true
|
||||
else runCatching { lipFocus.requestFocus() }
|
||||
}
|
||||
true
|
||||
}
|
||||
},
|
||||
) {
|
||||
button {
|
||||
if (launching) return@button
|
||||
if (open || shown) { launching = true; open = false } else onPrimary()
|
||||
}
|
||||
}
|
||||
// Gone with the words while the sheet is up; nothing a pad or a finger can reach then.
|
||||
val besideShown = !(open || shown)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.graphicsLayer { alpha = 1f - sink.value }
|
||||
.focusProperties { canFocus = besideShown },
|
||||
) { if (besideShown || sink.value < 0.99f) beside() }
|
||||
}
|
||||
}
|
||||
|
||||
// The lip, under Play: stretched by the pull, gone as the sheet lifts.
|
||||
val r0 = restRect
|
||||
// A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on.
|
||||
if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) {
|
||||
var handleW by remember { mutableStateOf(0f) }
|
||||
val lipH = 32.dp + pull.value.dp
|
||||
val lift = { if (!open) open = true }
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) }
|
||||
.onSizeChanged { handleW = it.width.toFloat() }
|
||||
.graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) }
|
||||
// 44dp to touch, with a 32dp visible handle.
|
||||
.heightIn(min = 44.dp).testTag("sheet-open")
|
||||
.semantics { contentDescription = settingsLabel }
|
||||
.then(
|
||||
if (downOpens) Modifier.focusProperties { canFocus = false }
|
||||
else Modifier.focusRequester(lipFocus).onFocusChanged { lipFocused = it.isFocused }.controllerConfirm(onClick = lift),
|
||||
)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = lift),
|
||||
) {
|
||||
Box(Modifier.glideBorder(lipFocused, RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp), LocalPalette.current.signal)) {
|
||||
Handle(lipH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's handle, tucked under Play: "Settings" and a chevron that bobs up every so often,
|
||||
* saying there is a drawer to pull up. Still with animations off.
|
||||
*/
|
||||
@Composable
|
||||
private fun Handle(height: Dp) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
|
||||
val still = Motion.scale == 0f
|
||||
val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle")
|
||||
// A quick lift and settle, then a rest: a nudge, not a pulse.
|
||||
val lift by bob.animateFloat(
|
||||
0f, 0f,
|
||||
androidx.compose.animation.core.infiniteRepeatable(
|
||||
androidx.compose.animation.core.keyframes {
|
||||
durationMillis = 2400
|
||||
0f at 0
|
||||
1f at 180 using androidx.compose.animation.core.FastOutSlowInEasing
|
||||
-0.25f at 420
|
||||
0f at 620
|
||||
},
|
||||
),
|
||||
label = "handleBob",
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.height(height).clip(shape).background(colors.surface)
|
||||
.drawBorderSidesAndFoot(pal.line, 12.dp)
|
||||
.padding(start = 14.dp, end = 10.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.drawer_page_settings), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1)
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A 1dp outline down the sides and along the foot, none along the top (the lip tucks under Play). */
|
||||
private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier =
|
||||
this.drawWithContent {
|
||||
drawContent()
|
||||
val c = corner.toPx()
|
||||
val sw = 1.dp.toPx()
|
||||
val p = Path().apply {
|
||||
moveTo(sw / 2, 0f)
|
||||
lineTo(sw / 2, size.height - c)
|
||||
arcTo(Rect(sw / 2, size.height - 2 * c + sw / 2, 2 * c, size.height - sw / 2), 180f, -90f, false)
|
||||
lineTo(size.width - c, size.height - sw / 2)
|
||||
arcTo(Rect(size.width - 2 * c, size.height - 2 * c + sw / 2, size.width - sw / 2, size.height - sw / 2), 90f, -90f, false)
|
||||
lineTo(size.width - sw / 2, 0f)
|
||||
}
|
||||
drawPath(p, color, style = Stroke(sw))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's chips: equal capsules with one selection gliding under them (the stores' chip row),
|
||||
* LB and RB a chip at a time, a tap or A on a far one a hop as a drop. A pad picks a chip by
|
||||
* moving onto it, and comes into the row on the picked one. [first] takes focus as the sheet
|
||||
* opens. Too narrow for equal chips, the row scrolls.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
internal fun SheetChips(labels: List<String>, selected: Int, focus: List<FocusRequester>? = null, onPick: (Int) -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val tint = rememberUpdatedState(pal.signal)
|
||||
// Each chip its own requester: one moved from chip to chip under a focused chip lost the ring.
|
||||
val chips = focus ?: remember(labels.size) { List(labels.size) { FocusRequester() } }
|
||||
var within by remember { mutableStateOf(false) }
|
||||
// LB / RB with a pad on the chips: focus goes with the selection.
|
||||
LaunchedEffect(selected) { if (within) runCatching { chips[selected].requestFocus() } }
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }) {
|
||||
val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp
|
||||
val scroll = rememberScrollState()
|
||||
// Room inside the scroll for the focus ring, which the scroll's clip would otherwise cut.
|
||||
Box(if (equal) Modifier.fillMaxWidth() else Modifier.edgeFade(scroll).horizontalScroll(scroll).padding(3.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
// Up from the rows lands on the picked chip, not whichever sits above: landing picks.
|
||||
modifier = (if (equal) Modifier.fillMaxWidth() else Modifier)
|
||||
.focusProperties { enter = { chips.getOrNull(selected) ?: FocusRequester.Default } }
|
||||
.focusGroup(),
|
||||
) {
|
||||
labels.forEachIndexed { i, label ->
|
||||
val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = 64.dp))
|
||||
.onGloballyPositioned { bounds[i] = it.boundsInParent() }
|
||||
.focusRequester(chips[i])
|
||||
SheetChip(label, i == selected, m.onFocusChanged { if (it.isFocused && i != selected) onPick(i) }) { onPick(i) }
|
||||
}
|
||||
}
|
||||
// Over the chips: under them, their rest fill hid it as it crossed them.
|
||||
ChipGlide(selected, bounds[selected], tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
// The row's glide draws the picked chip's look by touch only; with a pad it draws its own rest
|
||||
// look, and the bar says which is picked.
|
||||
val under = on && LocalInputModeManager.current.inputMode != InputMode.Keyboard
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.heightIn(min = 44.dp).clip(shape)
|
||||
.background(if (under) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f))
|
||||
.shownBar(on, pal.signal)
|
||||
.glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only line that is a link: [title], [value] under it, and "[link] ↗" at the right in the
|
||||
* signal blue - not a picker, so no value box. A (or a tap) follows it. [id] is its focus key.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LinkRow(title: String, value: String, link: String, id: String, arrow: String = "↗", onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().paneItem(id).hopSource(id).heightIn(min = 56.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 11.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(value, fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text("$link $arrow", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -152,293 +154,171 @@ class ModeSettingsActions(
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
private enum class ModeSettingsTab(val label: Int) {
|
||||
DISPLAY(R.string.drawer_page_display),
|
||||
CONTROLS(R.string.drawer_page_controls),
|
||||
GAMES(R.string.game_settings_title),
|
||||
AUDIO(R.string.mode_audio),
|
||||
SESSION(R.string.mode_session),
|
||||
STEAM(R.string.mode_tab_steam),
|
||||
/**
|
||||
* The chips of a mode's lifted sheet, in order: Steam's six; the desktop's Display, Controls and
|
||||
* Session (its Files chip is the Desktop page's own).
|
||||
*/
|
||||
internal fun modeSheetTabs(steam: Boolean): List<String> =
|
||||
if (steam) listOf(SheetTab.STEAM, SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.AUDIO, SheetTab.SESSION, SheetTab.LIBRARY)
|
||||
else listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.SESSION)
|
||||
|
||||
/** A sheet tab's chip label. */
|
||||
@Composable
|
||||
internal fun sheetTabLabel(tab: String): String = when (tab) {
|
||||
SheetTab.DISPLAY -> stringResource(R.string.drawer_page_display)
|
||||
SheetTab.CONTROLS -> stringResource(R.string.drawer_page_controls)
|
||||
SheetTab.AUDIO -> stringResource(R.string.mode_audio)
|
||||
SheetTab.SESSION -> stringResource(R.string.mode_session)
|
||||
SheetTab.LIBRARY -> stringResource(R.string.stores_tab_library)
|
||||
SheetTab.STEAM -> stringResource(R.string.mode_tab_steam)
|
||||
else -> stringResource(R.string.setup_tool_files)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows the front end adds to a sheet's tabs: frame generation (Display), Performance (Session) and
|
||||
* the Graphics drivers link to Components (Display, given the drivers' summary).
|
||||
*/
|
||||
class SheetExtras(
|
||||
val frameGen: @Composable () -> Unit = {},
|
||||
val performance: @Composable () -> Unit = {},
|
||||
val drivers: @Composable (summary: String?) -> Unit = {},
|
||||
/** The Steam tab's account rows (offline mode), from the front end's own state. */
|
||||
val account: @Composable (host: MenuHost) -> Unit = {},
|
||||
)
|
||||
|
||||
/** One tab of a mode's lifted sheet: the rows the settings page had, grouped by what they are for. */
|
||||
@Composable
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SheetExtras = SheetExtras()) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
var explainWifiDiscovery by remember { mutableStateOf(false) }
|
||||
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
|
||||
val tabs = if (steam) ModeSettingsTab.entries else listOf(
|
||||
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
|
||||
)
|
||||
val tab = tabs[tabIndex]
|
||||
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
|
||||
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
|
||||
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
fun selectTab(index: Int) { host.open = null; tabIndex = index }
|
||||
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching {
|
||||
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
|
||||
else tabFocus[tabIndex].requestFocus()
|
||||
if (tab == SheetTab.DISPLAY) {
|
||||
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
|
||||
SettingsGroup(stringResource(R.string.display_session)) {
|
||||
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution)
|
||||
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
|
||||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
|
||||
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
|
||||
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
|
||||
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
|
||||
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
|
||||
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
|
||||
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
|
||||
)
|
||||
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_frame_rate)) {
|
||||
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
extras.frameGen()
|
||||
extras.drivers(s.gpuDrivers)
|
||||
}
|
||||
if (tab == SheetTab.CONTROLS) {
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam && s.unfoldedControls != null) ToggleRow(
|
||||
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint),
|
||||
s.unfoldedControls, onChange = a.onUnfoldedControls,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
title = null,
|
||||
onBack = a.onDismiss,
|
||||
scroll = scrolls[tabIndex],
|
||||
modifier = Modifier.bumpers(
|
||||
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
|
||||
onNext = { selectTab((tabIndex + 1) % tabs.size) },
|
||||
),
|
||||
header = {
|
||||
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
|
||||
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
|
||||
},
|
||||
) {
|
||||
if (tab == ModeSettingsTab.DISPLAY) {
|
||||
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
|
||||
SettingsGroup(stringResource(R.string.display_session)) {
|
||||
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
|
||||
chipModifier = Modifier.focusRequester(firstChip))
|
||||
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
|
||||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
|
||||
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
|
||||
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
|
||||
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
|
||||
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
|
||||
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
|
||||
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
|
||||
)
|
||||
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_frame_rate)) {
|
||||
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tab == ModeSettingsTab.CONTROLS) {
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam && s.unfoldedControls != null) ToggleRow(
|
||||
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint),
|
||||
s.unfoldedControls, onChange = a.onUnfoldedControls,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (tab == ModeSettingsTab.SESSION) {
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
) + if (steam) listOf(
|
||||
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
|
||||
) else emptyList(),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
s.pipAutoEnter, onChange = a.onPipAutoEnter)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
|
||||
ToggleRow(
|
||||
host, "steam-startup", stringResource(R.string.mode_steam_startup),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
s.deckyStage != null -> s.deckyStage
|
||||
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
|
||||
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
|
||||
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
|
||||
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
|
||||
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
|
||||
val action = when {
|
||||
s.deckyStage != null -> stringResource(R.string.common_working)
|
||||
s.deckyChecking -> stringResource(R.string.common_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
|
||||
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
|
||||
else -> stringResource(R.string.common_check)
|
||||
}
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(
|
||||
action,
|
||||
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
|
||||
) {
|
||||
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
|
||||
else s.deckyLatestRelease?.let(a.onDeckyInstall)
|
||||
}
|
||||
if (s.deckyInstalled != null) SecondaryButton(
|
||||
stringResource(R.string.common_uninstall),
|
||||
enabled = s.deckyStage == null && !s.deckySessionRunning,
|
||||
) { confirmDeckyRemoval = true }
|
||||
}
|
||||
}
|
||||
if (s.deckyInstalled != null) ToggleRow(
|
||||
host, "decky-enabled", stringResource(R.string.mode_decky),
|
||||
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
|
||||
else stringResource(R.string.mode_decky_off),
|
||||
s.deckyEnabled,
|
||||
enabled = !s.deckySessionRunning && s.deckyStage == null,
|
||||
onChange = a.onDeckyEnabled,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_decky_plugins),
|
||||
stringResource(R.string.mode_decky_plugins_hint),
|
||||
) {
|
||||
SecondaryButton(
|
||||
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
|
||||
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
|
||||
onClick = a.onPickDeckyPluginZip,
|
||||
)
|
||||
}
|
||||
if (s.deckyStage != null && s.deckyPercent >= 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { s.deckyPercent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "mangoapp", stringResource(R.string.mode_mangoapp),
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
) {
|
||||
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
val hint = when {
|
||||
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
|
||||
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
|
||||
else -> stringResource(R.string.mode_wifi_discovery_hint)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
||||
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
|
||||
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
|
||||
}
|
||||
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
|
||||
host.open = null
|
||||
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (s.syncBackend != null) SettingsRow(
|
||||
stringResource(R.string.sync_backend_title),
|
||||
stringResource(R.string.sync_backend_hint),
|
||||
) {
|
||||
SegmentedTabs(
|
||||
listOf(
|
||||
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
|
||||
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
|
||||
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
|
||||
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
|
||||
),
|
||||
s.syncBackend,
|
||||
) { id -> host.open = null; a.onSyncBackend(id) }
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
|
||||
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
|
||||
)
|
||||
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
|
||||
GameEnvironmentRow()
|
||||
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
if (tab == SheetTab.AUDIO && steam) {
|
||||
if (s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
|
||||
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
}
|
||||
if (tab == SheetTab.SESSION) {
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
) + if (steam) listOf(
|
||||
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
|
||||
) else emptyList(),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
s.pipAutoEnter, onChange = a.onPipAutoEnter)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
|
||||
ToggleRow(
|
||||
host, "steam-startup", stringResource(R.string.mode_steam_startup),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
extras.performance()
|
||||
}
|
||||
if (tab == SheetTab.LIBRARY && steam) {
|
||||
if (s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
|
||||
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
|
||||
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
|
||||
@@ -481,6 +361,97 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (tab == SheetTab.STEAM && steam) {
|
||||
extras.account(host)
|
||||
SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
s.deckyStage != null -> s.deckyStage
|
||||
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
|
||||
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
|
||||
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
|
||||
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
|
||||
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
|
||||
val action = when {
|
||||
s.deckyStage != null -> stringResource(R.string.common_working)
|
||||
s.deckyChecking -> stringResource(R.string.common_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
|
||||
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
|
||||
else -> stringResource(R.string.common_check)
|
||||
}
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(
|
||||
action,
|
||||
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
|
||||
) {
|
||||
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
|
||||
else s.deckyLatestRelease?.let(a.onDeckyInstall)
|
||||
}
|
||||
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
|
||||
}
|
||||
}
|
||||
if (s.deckyInstalled != null) ToggleRow(
|
||||
host, "decky-enabled", stringResource(R.string.mode_decky),
|
||||
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
|
||||
else stringResource(R.string.mode_decky_off),
|
||||
s.deckyEnabled,
|
||||
enabled = !s.deckySessionRunning && s.deckyStage == null,
|
||||
onChange = a.onDeckyEnabled,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_decky_plugins),
|
||||
stringResource(R.string.mode_decky_plugins_hint),
|
||||
) {
|
||||
SecondaryButton(
|
||||
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
|
||||
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
|
||||
onClick = a.onPickDeckyPluginZip,
|
||||
)
|
||||
}
|
||||
if (s.deckyStage != null && s.deckyPercent >= 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { s.deckyPercent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "mangoapp", stringResource(R.string.mode_mangoapp),
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
) {
|
||||
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
|
||||
}
|
||||
}
|
||||
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
val hint = when {
|
||||
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
|
||||
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
|
||||
else -> stringResource(R.string.mode_wifi_discovery_hint)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
||||
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
|
||||
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
|
||||
}
|
||||
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
|
||||
host.open = null
|
||||
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (explainWifiDiscovery) AlertDialog(
|
||||
onDismissRequest = { explainWifiDiscovery = false },
|
||||
title = { Text(stringResource(R.string.mode_wifi_explain_title)) },
|
||||
@@ -494,15 +465,66 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
TextButton(onClick = { explainWifiDiscovery = false }) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
if (confirmDeckyRemoval) AlertDialog(
|
||||
onDismissRequest = { confirmDeckyRemoval = false },
|
||||
title = { Text(stringResource(R.string.mode_decky_remove_title)) },
|
||||
text = { Text(stringResource(R.string.mode_decky_remove_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text(stringResource(R.string.common_uninstall)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
}
|
||||
|
||||
/**
|
||||
* The options that apply to every Proton - how Wine syncs, FEX's preset, Force SSBS and the games'
|
||||
* environment - for the Components page's Proton layer.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProtonOptions(
|
||||
syncBackend: String, fexPreset: String, forceSsbs: Boolean,
|
||||
onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
SettingsRow(stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), stacked = true) {
|
||||
SegmentedTabs(
|
||||
listOf(
|
||||
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
|
||||
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
|
||||
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
|
||||
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
|
||||
),
|
||||
syncBackend,
|
||||
) { id -> host.open = null; onSyncBackend(id) }
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, fexPreset,
|
||||
note = stringResource(FexPreset.byId(fexPreset).detail), onPick = onFexPreset,
|
||||
)
|
||||
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), forceSsbs, onChange = onForceSsbs)
|
||||
GameEnvironmentRow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall for Decky Loader: are you sure steps out of it in red, Cancel first, so a stray A
|
||||
* costs nothing.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.common_uninstall)
|
||||
val title = stringResource(R.string.mode_decky_remove_title)
|
||||
val note = stringResource(R.string.mode_decky_remove_text)
|
||||
val cancel = stringResource(R.string.common_cancel)
|
||||
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||
androidx.compose.foundation.layout.Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
|
||||
handle = { Text(label, fontSize = 15.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = colors.error, maxLines = 1) },
|
||||
) {
|
||||
StepTitle(title)
|
||||
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.stepItem(2)) {
|
||||
StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
|
||||
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Width × height for the session, with the common handheld shapes one tap away. */
|
||||
|
||||
@@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
@@ -44,9 +46,38 @@ import kotlin.math.sin
|
||||
// Play (LaunchFlood): the control stretches out over the pane on loose springs, the far edges
|
||||
// first, and the page rises into it once it is covered. Back draws it down into the control again.
|
||||
|
||||
/** Where a page opened from: the control's bounds and corner radius, in px. */
|
||||
internal class Origin(val bounds: Rect, val corner: Float) {
|
||||
fun translate(by: Offset) = Origin(bounds.translate(by), corner)
|
||||
/**
|
||||
* Where a page opened from: the control's bounds and corner radius, in px, and the rail item that
|
||||
* was showing it ([owner]), so the page only draws back into it when that content returns. [art]
|
||||
* is the control's picture for the tile (a drawable id, or an image Coil loads), if it has one.
|
||||
*/
|
||||
internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null, val art: Any? = null) {
|
||||
fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner, art)
|
||||
fun ownedBy(key: String) = Origin(bounds, corner, key, art)
|
||||
}
|
||||
|
||||
/** How a page that opened from a control leaves. */
|
||||
internal enum class PageExit {
|
||||
/** Back into the control it came from, which is on screen again. */
|
||||
Drain,
|
||||
/** Out through a link into another section, which takes the page there itself. */
|
||||
Hop,
|
||||
/** Away with the pane, as any page goes: the control it came from is not coming back. */
|
||||
Sink,
|
||||
}
|
||||
|
||||
/** Why a page is leaving: an ordinary change of page, or a link into another section. */
|
||||
internal enum class PageLeave { Ordinary, Link }
|
||||
|
||||
/**
|
||||
* The exit for a page opened from a control on [owner]'s content, leaving for [incoming]. It only
|
||||
* draws back into the control when the content that control belongs to is what comes back; a page
|
||||
* left for another rail item or another page drained into a control that was no longer there.
|
||||
*/
|
||||
internal fun pageExit(owner: String?, incoming: String, leave: PageLeave): PageExit = when {
|
||||
owner != null && incoming == owner -> PageExit.Drain
|
||||
leave == PageLeave.Link -> PageExit.Hop
|
||||
else -> PageExit.Sink
|
||||
}
|
||||
|
||||
internal object PageOrigin {
|
||||
@@ -70,6 +101,7 @@ internal const val PAGE_RETURN_MS = 900
|
||||
* [content], a page that opened from the control at [from] (in this element's coordinates): the
|
||||
* control's tile stretching over it until covered, then the page rising in as the tile fades.
|
||||
* Once [leaving], the reverse: the tile comes back over the page and draws down into the control.
|
||||
* A page that [sinks] instead goes with the pane and draws no tile at all.
|
||||
* The focus ring stays out of it until it is done.
|
||||
*/
|
||||
@Composable
|
||||
@@ -78,6 +110,15 @@ internal fun PageFlood(
|
||||
leaving: Boolean,
|
||||
/** What the tile shows instead of the control's fill: a game card's art, cropped to the tile as it grows. */
|
||||
art: Painter? = null,
|
||||
sinks: Boolean = false,
|
||||
/**
|
||||
* Where on the page the control's tile lands (root px), when the page has its own picture of
|
||||
* the control - a game page's hero art: the tile grows onto it instead of over the whole pane,
|
||||
* corner to corner ([ontoCorner] there), and the page comes in around it. Back leaves from it.
|
||||
* Null, or off screen: the pane, as any page.
|
||||
*/
|
||||
onto: (() -> Rect?)? = null,
|
||||
ontoCorner: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val pal = LocalPalette.current
|
||||
@@ -87,9 +128,44 @@ internal fun PageFlood(
|
||||
val shown = remember { Animatable(0f) }
|
||||
val tile = remember { Animatable(1f) }
|
||||
var size by remember { mutableStateOf(Size.Zero) }
|
||||
LaunchedEffect(size != Size.Zero, leaving) {
|
||||
var at by remember { mutableStateOf(Offset.Zero) }
|
||||
// Where this flood lands (in its own space), read as it opens and again as it leaves.
|
||||
var target by remember { mutableStateOf<Rect?>(null) }
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
LaunchedEffect(size != Size.Zero, leaving, sinks) {
|
||||
if (sinks) {
|
||||
// Whatever the flood in was doing, the page is shown whole and leaves with the pane.
|
||||
tile.snapTo(0f)
|
||||
shown.snapTo(1f)
|
||||
glide?.hidden = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (size == Size.Zero) return@LaunchedEffect
|
||||
glide?.hidden = true
|
||||
// The page lays out under the tile at once, so its picture is there within a frame or two.
|
||||
target = onto?.let { get ->
|
||||
kotlinx.coroutines.withTimeoutOrNull(300) { snapshotFlow { get() }.first { it != null } }
|
||||
}?.translate(-at)?.takeIf { it.height > 1f && it.overlaps(Rect(Offset.Zero, size)) }
|
||||
if (target != null) {
|
||||
coroutineScope {
|
||||
if (!leaving) {
|
||||
// Onto the page's picture, every edge together on one firm spring: no blob, and
|
||||
// nowhere to go past it.
|
||||
val runs = edges.map { launch { it.animateTo(1f, Motion.sp(0.85f, 320f)) } }
|
||||
snapshotFlow { edges.all { it.value >= 0.9f } }.first { it }
|
||||
launch { shown.animateTo(1f, Motion.tw(240)) }
|
||||
launch { delay(Motion.ms(140).toLong()); tile.animateTo(0f, Motion.tw(220)) }
|
||||
runs.forEach { it.join() }
|
||||
} else {
|
||||
launch { shown.animateTo(0f, Motion.tw(160)) }
|
||||
tile.animateTo(1f, Motion.tw(120))
|
||||
edges.map { launch { it.animateTo(0f, Motion.sp(0.9f, 320f)) } }.forEach { it.join() }
|
||||
tile.animateTo(0f, Motion.tw(120))
|
||||
}
|
||||
}
|
||||
glide?.hidden = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val b = from.bounds
|
||||
val travel = listOf(b.left, b.top, size.width - b.right, size.height - b.bottom).map { it.coerceAtLeast(0f) }
|
||||
val far = travel.max().coerceAtLeast(1f)
|
||||
@@ -122,10 +198,16 @@ internal fun PageFlood(
|
||||
glide?.hidden = false
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { glide?.hidden = false } }
|
||||
Box(Modifier.fillMaxSize().clipToBounds().onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }) {
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value; translationY = (1f - shown.value) * 14.dp.toPx() }) { content() }
|
||||
Box(
|
||||
Modifier.fillMaxSize().clipToBounds().onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }
|
||||
.onGloballyPositioned { at = it.positionInRoot() },
|
||||
) {
|
||||
// Landing on the page's picture, the page must not rise: its picture would move off the tile.
|
||||
val rise = if (onto != null) 0f else 1f
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value; translationY = (1f - shown.value) * 14.dp.toPx() * rise }) { content() }
|
||||
val onCorner = with(density) { ontoCorner.toPx() }
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
if (tile.value > 0.01f) drawTile(from, edges.map { it.value }, pal.surface, pal.line2, tile.value, art)
|
||||
if (tile.value > 0.01f) drawTile(from, edges.map { it.value }, pal.surface, pal.line2, tile.value, art, target, onCorner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,22 +216,33 @@ internal fun PageFlood(
|
||||
* The control's tile with its edges [v] of the way to the pane's (left, top, right, bottom):
|
||||
* sinking with the page behind, blobby in flight, square once it fills the pane.
|
||||
*/
|
||||
private fun DrawScope.drawTile(from: Origin, v: List<Float>, fill: Color, line: Color, alpha: Float, art: Painter? = null) {
|
||||
private fun DrawScope.drawTile(
|
||||
from: Origin, v: List<Float>, fill: Color, line: Color, alpha: Float, art: Painter? = null,
|
||||
/** Lands on this instead of the pane (a page's picture of the control), corner [toCorner]. */
|
||||
to: Rect? = null, toCorner: Float = 0f,
|
||||
) {
|
||||
val (l, t, r, b) = v
|
||||
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
|
||||
// The control sinks with the page behind it (scaled to 0.95 about the pane's centre).
|
||||
val k = 1f - 0.05f * mean
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val f = from.bounds
|
||||
val left = lerp(cx + (f.left - cx) * k, 0f, l)
|
||||
val top = lerp(cy + (f.top - cy) * k, 0f, t)
|
||||
val right = lerp(cx + (f.right - cx) * k, size.width, r)
|
||||
val bottom = lerp(cy + (f.bottom - cy) * k, size.height, b)
|
||||
val left: Float; val top: Float; val right: Float; val bottom: Float
|
||||
if (to != null) {
|
||||
left = lerp(f.left, to.left, l); top = lerp(f.top, to.top, t)
|
||||
right = lerp(f.right, to.right, r); bottom = lerp(f.bottom, to.bottom, b)
|
||||
} else {
|
||||
// The control sinks with the page behind it (scaled to 0.95 about the pane's centre).
|
||||
val k = 1f - 0.05f * mean
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
left = lerp(cx + (f.left - cx) * k, 0f, l)
|
||||
top = lerp(cy + (f.top - cy) * k, 0f, t)
|
||||
right = lerp(cx + (f.right - cx) * k, size.width, r)
|
||||
bottom = lerp(cy + (f.bottom - cy) * k, size.height, b)
|
||||
}
|
||||
val w = (right - left).coerceAtLeast(0f)
|
||||
val h = (bottom - top).coerceAtLeast(0f)
|
||||
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
|
||||
val corner = lerp(from.corner * (1f - mean), minOf(w, h) * 0.42f, blob)
|
||||
// Onto a picture it keeps a card's shape, corner to corner; over the pane it blobs in flight.
|
||||
val corner = if (to != null) lerp(from.corner, toCorner, mean).coerceAtMost(minOf(w, h) / 2f)
|
||||
else lerp(from.corner * (1f - mean), minOf(w, h) * 0.42f, sin(PI * mean).toFloat().coerceAtLeast(0f))
|
||||
drawRoundRect(fill, Offset(left, top), Size(w, h), CornerRadius(corner), alpha = alpha)
|
||||
val pic = art?.intrinsicSize?.takeIf { it.isSpecified && it.width > 0f && it.height > 0f }
|
||||
if (art != null && pic != null && w > 0f && h > 0f) {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean)
|
||||
|
||||
@Composable
|
||||
fun ProtonPage(
|
||||
rows: List<ProtonRow>,
|
||||
busyId: String?,
|
||||
stage: String?,
|
||||
percent: Int,
|
||||
runtimeReady: Boolean,
|
||||
sessionRunning: Boolean,
|
||||
onInstall: (String) -> Unit,
|
||||
onCancel: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
val colors = MaterialTheme.colorScheme
|
||||
SettingsPage(
|
||||
host,
|
||||
title = stringResource(R.string.setup_tool_protons),
|
||||
eyebrow = stringResource(R.string.setup_title),
|
||||
lede = stringResource(R.string.proton_lede),
|
||||
onBack = onBack,
|
||||
) {
|
||||
if (!runtimeReady) Text(
|
||||
stringResource(R.string.content_needs_runtime),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
if (sessionRunning) Text(
|
||||
stringResource(R.string.proton_stop_first),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
val compatible = stringResource(R.string.steam_compat_label)
|
||||
Text(
|
||||
stringResource(R.string.proton_compatible_note, compatible),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
SettingsGroup(stringResource(R.string.proton_available)) {
|
||||
for (row in rows) {
|
||||
SettingsRow(
|
||||
row.name,
|
||||
when {
|
||||
row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible)
|
||||
row.queued -> stringResource(R.string.proton_queued)
|
||||
else -> stringResource(R.string.proton_not_installed)
|
||||
},
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
when {
|
||||
row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) }
|
||||
busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {}
|
||||
else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) }
|
||||
}
|
||||
if (row.queued && row.installed == null && busyId == null) {
|
||||
SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
|
||||
Text(
|
||||
if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
|
||||
)
|
||||
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user