mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-22 07:04:40 -04:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
923ffde715 | ||
|
|
31421d7c02 | ||
|
|
f08e7cdcaa | ||
|
|
9f5e258bd6 | ||
|
|
a863ca7b31 | ||
|
|
6b2ca398e2 | ||
|
|
779703a56b | ||
|
|
9a86fc6ee1 | ||
|
|
4cd6b46dea | ||
|
|
bacfa942a0 | ||
|
|
b68bb054a9 | ||
|
|
af37432251 | ||
|
|
c0703cb809 | ||
|
|
829a39a9ca | ||
|
|
8b40bb5c88 | ||
|
|
a0dbdd6b9a | ||
|
|
aaaa18a7dc | ||
|
|
da26822714 | ||
|
|
c178355d92 | ||
|
|
1be8495ee5 | ||
|
|
81500fe184 | ||
|
|
cdc0866cfe | ||
|
|
6a3583d744 | ||
|
|
23bca1b974 | ||
|
|
d51f8ed4ff | ||
|
|
fba6a5d0f2 | ||
|
|
4992cf76dd | ||
|
|
c2c80c3540 | ||
|
|
7d691282fa | ||
|
|
dcb269d5ea | ||
|
|
f67398e6bc | ||
|
|
003064769d | ||
|
|
9fe8bf6955 |
No files matched your search
@@ -321,6 +321,17 @@ jobs:
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
mkdir -p pw-pkgs
|
||||
grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
|
||||
echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
|
||||
rm pw-pkgs/pkg.tar.zst
|
||||
done
|
||||
tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
|
||||
rm -rf pw-pkgs
|
||||
ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
|
||||
T="$D/usr/local/lib/droiddeck-msitools"
|
||||
mkdir -p "$T" msi-pkgs
|
||||
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
|
||||
@@ -478,6 +489,8 @@ jobs:
|
||||
/tmp/test-xr-tracking
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
|
||||
/tmp/test-xr-touch
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
|
||||
/tmp/test-pad-controller-config
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
|
||||
/tmp/test-xr-vsync
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
|
||||
|
||||
@@ -21,7 +21,7 @@ On Android 14 and newer, before Steam launches, you must turn on **Disable child
|
||||
|
||||
## Smart glasses
|
||||
|
||||
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, and XREAL connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
|
||||
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, XREAL, and Rokid connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
|
||||
|
||||
## Community
|
||||
|
||||
@@ -49,10 +49,10 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
|
||||
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
|
||||
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
|
||||
|
||||
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
|
||||
|
||||
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
|
||||
|
||||
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment.
|
||||
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment. Chromium and Electron apps from Flathub start their browser processes through [zypak](https://github.com/refi64/zypak) by [@refi64](https://github.com/refi64) (BSD-3-Clause); its source was read to see that it always spawns them through Flatpak's spawn portal, which is why DroidDeck starts them directly without Chromium's sandbox instead.
|
||||
@@ -177,15 +177,6 @@
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.SecondScreenActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/SessionTheme"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
|
||||
|
||||
<activity
|
||||
android:name=".gpu.SteamVrRestartActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -39,6 +39,10 @@ target_link_libraries(deviceinfo dl)
|
||||
|
||||
add_library(termux SHARED termux/termux.c)
|
||||
|
||||
add_library(ddvenc SHARED venc/venc_server.cpp)
|
||||
target_compile_features(ddvenc PRIVATE cxx_std_17)
|
||||
target_link_libraries(ddvenc mediandk log)
|
||||
|
||||
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
|
||||
if(DROIDDECK_VITURE_SDK_DIR)
|
||||
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
|
||||
|
||||
@@ -1184,8 +1184,9 @@ struct TritonSharedData {
|
||||
uint32_t input_length;
|
||||
uint8_t input[64];
|
||||
uint64_t output_seq;
|
||||
uint32_t output_feature;
|
||||
uint32_t output_length;
|
||||
uint16_t output_feature;
|
||||
uint16_t output_length;
|
||||
uint32_t output_ack;
|
||||
uint8_t output[64];
|
||||
uint64_t get_request;
|
||||
uint64_t get_reply;
|
||||
@@ -1202,6 +1203,8 @@ static_assert(offsetof(TritonSharedData, report_id) == 16, "Triton report ID off
|
||||
static_assert(offsetof(TritonSharedData, input) == 24, "Triton input offset");
|
||||
static_assert(offsetof(TritonSharedData, output_seq) == 88, "Triton output sequence offset");
|
||||
static_assert(offsetof(TritonSharedData, output_feature) == 96, "Triton output feature offset");
|
||||
static_assert(offsetof(TritonSharedData, output_length) == 98, "Triton output length offset");
|
||||
static_assert(offsetof(TritonSharedData, output_ack) == 100, "Triton output ack offset");
|
||||
static_assert(offsetof(TritonSharedData, output) == 104, "Triton output offset");
|
||||
static_assert(offsetof(TritonSharedData, get_request) == 168, "Triton feature request offset");
|
||||
static_assert(offsetof(TritonSharedData, get_reply) == 176, "Triton feature reply offset");
|
||||
@@ -1219,6 +1222,8 @@ static constexpr int TRITON_REPORT_ID_BYTES = 1;
|
||||
static constexpr int TRITON_STATE_BYTES = 45;
|
||||
static constexpr int TRITON_REPORT_INTERVAL_US = 4000;
|
||||
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500;
|
||||
// The app clears output_length once it has taken a report; a writer waits this long for that before overwriting.
|
||||
static constexpr int TRITON_OUTPUT_TIMEOUT_MS = 50;
|
||||
static constexpr const char *TRITON_NAME = "Steam Controller";
|
||||
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
|
||||
static const uint8_t kTritonReportDescriptor[] = {
|
||||
@@ -2574,11 +2579,21 @@ __attribute__((visibility("hidden"))) static void triton_queue_output(DeckHidraw
|
||||
if (!data || !size || size > 64 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return;
|
||||
auto *shared = reinterpret_cast<TritonSharedData *>(reinterpret_cast<uint8_t *>(triton.ring) + TRITON_SHARED_OFFSET);
|
||||
if (__atomic_load_n(&shared->magic, __ATOMIC_ACQUIRE) != TRITON_SHARED_MAGIC) return;
|
||||
static std::mutex one_at_a_time;
|
||||
std::lock_guard<std::mutex> guard(one_at_a_time);
|
||||
for (int waited = 0;
|
||||
static_cast<uint32_t>(__atomic_load_n(&shared->output_seq, __ATOMIC_ACQUIRE)) !=
|
||||
__atomic_load_n(&shared->output_ack, __ATOMIC_ACQUIRE) &&
|
||||
__atomic_load_n(&shared->connected, __ATOMIC_ACQUIRE) != 0 && waited < TRITON_OUTPUT_TIMEOUT_MS * 4;
|
||||
waited++) {
|
||||
struct timespec interval = {0, 250 * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
uint64_t seq = __atomic_load_n(&shared->output_seq, __ATOMIC_RELAXED);
|
||||
if (seq & 1) seq++;
|
||||
__atomic_store_n(&shared->output_seq, seq + 1, __ATOMIC_RELEASE);
|
||||
shared->output_feature = feature ? 1 : 0;
|
||||
shared->output_length = static_cast<uint32_t>(size);
|
||||
shared->output_length = static_cast<uint16_t>(size);
|
||||
memcpy(shared->output, data, size);
|
||||
if (size < sizeof(shared->output)) memset(shared->output + size, 0, sizeof(shared->output) - size);
|
||||
__atomic_thread_fence(__ATOMIC_RELEASE);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#ifndef DROIDDECK_VENC_PROTO_H
|
||||
#define DROIDDECK_VENC_PROTO_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and
|
||||
* MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS;
|
||||
* every frame gets one reply, touches get none. */
|
||||
|
||||
#define VENC_MAX_BUFFERS 16
|
||||
|
||||
enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5 };
|
||||
enum venc_touch_action { VENC_TOUCH_DOWN = 0, VENC_TOUCH_MOVE = 1, VENC_TOUCH_UP = 2 };
|
||||
enum venc_codec { VENC_H264 = 0, VENC_HEVC = 1 };
|
||||
|
||||
struct venc_config {
|
||||
uint32_t type;
|
||||
uint32_t codec;
|
||||
uint32_t width, height, stride, rows;
|
||||
uint32_t fps_num, fps_den;
|
||||
uint32_t bitrate, peak_bitrate, vbr;
|
||||
uint32_t gop;
|
||||
int32_t profile, level;
|
||||
uint32_t inputs, outputs;
|
||||
uint32_t input_size, output_size;
|
||||
};
|
||||
|
||||
struct venc_frame {
|
||||
uint32_t type;
|
||||
uint32_t input, output;
|
||||
uint32_t bytes;
|
||||
uint32_t keyframe;
|
||||
int64_t pts_us;
|
||||
};
|
||||
|
||||
struct venc_done {
|
||||
uint32_t type;
|
||||
int32_t status;
|
||||
uint32_t bytes;
|
||||
uint32_t keyframe;
|
||||
};
|
||||
|
||||
struct venc_bitrate {
|
||||
uint32_t type;
|
||||
uint32_t bitrate;
|
||||
};
|
||||
|
||||
/* Remote Play's touch-as-mouse turned back into a touch; x and y are fractions of the screen in 1/65535. */
|
||||
struct venc_touch {
|
||||
uint32_t type;
|
||||
uint32_t action;
|
||||
uint32_t x, y;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,346 @@
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
#include <media/NdkMediaCodec.h>
|
||||
#include <media/NdkMediaFormat.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "venc_proto.h"
|
||||
|
||||
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckVenc", __VA_ARGS__)
|
||||
|
||||
namespace {
|
||||
|
||||
struct Mapping {
|
||||
uint8_t *data = nullptr;
|
||||
size_t size = 0;
|
||||
};
|
||||
|
||||
constexpr int32_t kColorFormatYuv420SemiPlanar = 21;
|
||||
constexpr uint32_t kBufferFlagKeyFrame = 1;
|
||||
constexpr uint32_t kBufferFlagCodecConfig = 2;
|
||||
constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt
|
||||
constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32)
|
||||
|
||||
int32_t avc_profile(int32_t v4l2) {
|
||||
switch (v4l2) {
|
||||
case 0: return 1;
|
||||
case 1: return 0x10000;
|
||||
case 2: return 2;
|
||||
case 3: return 4;
|
||||
case 4: return 8;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class Session {
|
||||
public:
|
||||
explicit Session(int fd) : fd_(fd) {}
|
||||
~Session() {
|
||||
release();
|
||||
close(fd_);
|
||||
}
|
||||
|
||||
void run() {
|
||||
for (;;) {
|
||||
uint8_t buf[256];
|
||||
int fds[VENC_MAX_BUFFERS * 2];
|
||||
int nfds = 0;
|
||||
ssize_t n = receive(buf, sizeof buf, fds, &nfds);
|
||||
if (n <= 0) return;
|
||||
uint32_t type;
|
||||
memcpy(&type, buf, sizeof type);
|
||||
if (type == VENC_CONFIG && n == (ssize_t)sizeof(venc_config)) {
|
||||
venc_config c;
|
||||
memcpy(&c, buf, sizeof c);
|
||||
bool ok = configure(c, fds, nfds);
|
||||
for (int i = 0; i < nfds; i++) close(fds[i]);
|
||||
reply(ok ? 0 : -1, 0, false);
|
||||
} else if (type == VENC_FRAME && n == (ssize_t)sizeof(venc_frame)) {
|
||||
venc_frame f;
|
||||
memcpy(&f, buf, sizeof f);
|
||||
uint32_t bytes = 0;
|
||||
bool key = false;
|
||||
bool ok = encode(f, &bytes, &key);
|
||||
reply(ok ? 0 : -1, bytes, key);
|
||||
} else if (type == VENC_TOUCH && n == (ssize_t)sizeof(venc_touch)) {
|
||||
venc_touch t;
|
||||
memcpy(&t, buf, sizeof t);
|
||||
touch(t);
|
||||
for (int i = 0; i < nfds; i++) close(fds[i]);
|
||||
} else if (type == VENC_BITRATE && n == (ssize_t)sizeof(venc_bitrate)) {
|
||||
venc_bitrate b;
|
||||
memcpy(&b, buf, sizeof b);
|
||||
set_bitrate(b.bitrate);
|
||||
for (int i = 0; i < nfds; i++) close(fds[i]);
|
||||
} else {
|
||||
LOG("unexpected message %u of %zd bytes; closing", type, n);
|
||||
for (int i = 0; i < nfds; i++) close(fds[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
ssize_t receive(uint8_t *buf, size_t len, int *fds, int *nfds) {
|
||||
char control[CMSG_SPACE(sizeof(int) * VENC_MAX_BUFFERS * 2)];
|
||||
iovec iov = {buf, len};
|
||||
msghdr m = {};
|
||||
m.msg_iov = &iov;
|
||||
m.msg_iovlen = 1;
|
||||
m.msg_control = control;
|
||||
m.msg_controllen = sizeof control;
|
||||
ssize_t n = recvmsg(fd_, &m, MSG_CMSG_CLOEXEC);
|
||||
for (cmsghdr *c = CMSG_FIRSTHDR(&m); n > 0 && c; c = CMSG_NXTHDR(&m, c)) {
|
||||
if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) continue;
|
||||
int count = (int)((c->cmsg_len - CMSG_LEN(0)) / sizeof(int));
|
||||
memcpy(fds, CMSG_DATA(c), sizeof(int) * std::min(count, VENC_MAX_BUFFERS * 2));
|
||||
*nfds = std::min(count, VENC_MAX_BUFFERS * 2);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
void reply(int32_t status, uint32_t bytes, bool key) {
|
||||
venc_done d = {VENC_DONE, status, bytes, key ? 1u : 0u};
|
||||
send(fd_, &d, sizeof d, MSG_NOSIGNAL);
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (codec_) {
|
||||
AMediaCodec_stop(codec_);
|
||||
AMediaCodec_delete(codec_);
|
||||
codec_ = nullptr;
|
||||
}
|
||||
for (auto &m : inputs_) munmap(m.data, m.size);
|
||||
for (auto &m : outputs_) munmap(m.data, m.size);
|
||||
inputs_.clear();
|
||||
outputs_.clear();
|
||||
header_.clear();
|
||||
}
|
||||
|
||||
AMediaCodec *create(const venc_config &c, bool with_profile) {
|
||||
const char *mime = c.codec == VENC_HEVC ? "video/hevc" : "video/avc";
|
||||
AMediaCodec *codec = AMediaCodec_createEncoderByType(mime);
|
||||
if (!codec) return nullptr;
|
||||
float fps = c.fps_num ? (float)c.fps_den / (float)c.fps_num : 60.0f;
|
||||
AMediaFormat *f = AMediaFormat_new();
|
||||
AMediaFormat_setString(f, AMEDIAFORMAT_KEY_MIME, mime);
|
||||
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_WIDTH, (int32_t)c.width);
|
||||
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_HEIGHT, (int32_t)c.height);
|
||||
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_COLOR_FORMAT, kColorFormatYuv420SemiPlanar);
|
||||
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_BIT_RATE, (int32_t)std::max(c.bitrate, 500000u));
|
||||
AMediaFormat_setInt32(f, "bitrate-mode", c.vbr ? 1 : 2);
|
||||
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_FRAME_RATE, fps);
|
||||
AMediaFormat_setFloat(f, "operating-rate", fps);
|
||||
AMediaFormat_setInt32(f, "priority", 0);
|
||||
AMediaFormat_setInt32(f, "latency", 1);
|
||||
AMediaFormat_setInt32(f, "max-bframes", 0);
|
||||
AMediaFormat_setInt32(f, "prepend-sps-pps-to-idr-frames", 1);
|
||||
float gop_seconds = c.gop && c.gop < 0xffff ? (float)c.gop / fps : 3600.0f;
|
||||
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, std::max(gop_seconds, 0.1f));
|
||||
if (with_profile) {
|
||||
int32_t profile = c.codec == VENC_HEVC ? (c.profile == 2 ? 2 : 1) : avc_profile(c.profile);
|
||||
if (profile) AMediaFormat_setInt32(f, "profile", profile);
|
||||
}
|
||||
media_status_t s = AMediaCodec_configure(codec, f, nullptr, nullptr, AMEDIACODEC_CONFIGURE_FLAG_ENCODE);
|
||||
AMediaFormat_delete(f);
|
||||
if (s == AMEDIA_OK) s = AMediaCodec_start(codec);
|
||||
if (s != AMEDIA_OK) {
|
||||
AMediaCodec_delete(codec);
|
||||
return nullptr;
|
||||
}
|
||||
return codec;
|
||||
}
|
||||
|
||||
bool configure(const venc_config &c, const int *fds, int nfds) {
|
||||
release();
|
||||
if (c.inputs + c.outputs != (uint32_t)nfds || c.width < 64 || c.height < 64 || c.stride < c.width ||
|
||||
c.rows < c.height || (uint64_t)c.stride * c.rows * 3 / 2 > c.input_size) {
|
||||
LOG("bad config: %u+%u buffers for %d fds, %ux%u stride %u", c.inputs, c.outputs, nfds, c.width, c.height,
|
||||
c.stride);
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < nfds; i++) {
|
||||
bool input = (uint32_t)i < c.inputs;
|
||||
size_t size = input ? c.input_size : c.output_size;
|
||||
void *p = mmap(nullptr, size, input ? PROT_READ : PROT_READ | PROT_WRITE, MAP_SHARED, fds[i], 0);
|
||||
if (p == MAP_FAILED) {
|
||||
LOG("could not map buffer %d: %s", i, strerror(errno));
|
||||
release();
|
||||
return false;
|
||||
}
|
||||
(input ? inputs_ : outputs_).push_back({(uint8_t *)p, size});
|
||||
}
|
||||
codec_ = create(c, true);
|
||||
if (!codec_) codec_ = create(c, false);
|
||||
if (!codec_) {
|
||||
LOG("no %s encoder for %ux%u", c.codec == VENC_HEVC ? "HEVC" : "H.264", c.width, c.height);
|
||||
release();
|
||||
return false;
|
||||
}
|
||||
config_ = c;
|
||||
dst_stride_ = (int32_t)c.width;
|
||||
dst_rows_ = (int32_t)c.height;
|
||||
using InputFormat = AMediaFormat *(*)(AMediaCodec *);
|
||||
static auto input_format = (InputFormat)dlsym(RTLD_DEFAULT, "AMediaCodec_getInputFormat");
|
||||
if (AMediaFormat *in = input_format ? input_format(codec_) : nullptr) {
|
||||
int32_t v;
|
||||
if (AMediaFormat_getInt32(in, "stride", &v) && v >= (int32_t)c.width) dst_stride_ = v;
|
||||
if (AMediaFormat_getInt32(in, "slice-height", &v) && v >= (int32_t)c.height) dst_rows_ = v;
|
||||
AMediaFormat_delete(in);
|
||||
}
|
||||
LOG("%s %ux%u at %.2f fps, %u kbit/s %s, input stride %d rows %d", c.codec == VENC_HEVC ? "HEVC" : "H.264",
|
||||
c.width, c.height, c.fps_num ? (double)c.fps_den / c.fps_num : 0.0, c.bitrate / 1000,
|
||||
c.vbr ? "VBR" : "CBR", dst_stride_, dst_rows_);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void touch(const venc_touch &t) {
|
||||
using SendTouch = void (*)(int, int, int, int);
|
||||
static SendTouch send_touch = [] {
|
||||
void *wayland = dlopen("libdroiddeckwayland.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
auto fn = wayland ? (SendTouch)dlsym(wayland, "droiddeck_wayland_send_touch") : nullptr;
|
||||
if (!fn) LOG("remote touch unavailable: %s", dlerror());
|
||||
return fn;
|
||||
}();
|
||||
if (!send_touch || t.action > VENC_TOUCH_UP) return;
|
||||
int x = (int)((uint64_t)std::min(t.x, 65535u) * (kTouchWidth - 1) / 65535);
|
||||
int y = (int)((uint64_t)std::min(t.y, 65535u) * (kTouchHeight - 1) / 65535);
|
||||
send_touch((int)t.action, kRemoteTouchId, x, y);
|
||||
}
|
||||
|
||||
void set_bitrate(uint32_t bitrate) {
|
||||
if (!codec_) return;
|
||||
AMediaFormat *p = AMediaFormat_new();
|
||||
AMediaFormat_setInt32(p, "video-bitrate", (int32_t)std::max(bitrate, 500000u));
|
||||
AMediaCodec_setParameters(codec_, p);
|
||||
AMediaFormat_delete(p);
|
||||
}
|
||||
|
||||
bool encode(const venc_frame &f, uint32_t *bytes, bool *key) {
|
||||
if (!codec_ || f.input >= inputs_.size() || f.output >= outputs_.size()) return false;
|
||||
if (f.keyframe) {
|
||||
AMediaFormat *p = AMediaFormat_new();
|
||||
AMediaFormat_setInt32(p, "request-sync", 0);
|
||||
AMediaCodec_setParameters(codec_, p);
|
||||
AMediaFormat_delete(p);
|
||||
}
|
||||
ssize_t in = AMediaCodec_dequeueInputBuffer(codec_, 100000);
|
||||
if (in < 0) return false;
|
||||
size_t capacity = 0;
|
||||
uint8_t *dst = AMediaCodec_getInputBuffer(codec_, (size_t)in, &capacity);
|
||||
size_t need = (size_t)dst_stride_ * dst_rows_ + (size_t)dst_stride_ * ((config_.height + 1) / 2);
|
||||
if (!dst || capacity < need) return false;
|
||||
const uint8_t *src = inputs_[f.input].data;
|
||||
for (uint32_t y = 0; y < config_.height; y++)
|
||||
memcpy(dst + (size_t)y * dst_stride_, src + (size_t)y * config_.stride, config_.width);
|
||||
const uint8_t *src_uv = src + (size_t)config_.stride * config_.rows;
|
||||
uint8_t *dst_uv = dst + (size_t)dst_stride_ * dst_rows_;
|
||||
for (uint32_t y = 0; y < (config_.height + 1) / 2; y++)
|
||||
memcpy(dst_uv + (size_t)y * dst_stride_, src_uv + (size_t)y * config_.stride, config_.width);
|
||||
if (AMediaCodec_queueInputBuffer(codec_, (size_t)in, 0, need, (uint64_t)std::max<int64_t>(f.pts_us, 0), 0) !=
|
||||
AMEDIA_OK)
|
||||
return false;
|
||||
Mapping &out = outputs_[f.output];
|
||||
for (int tries = 0; tries < 20; tries++) {
|
||||
AMediaCodecBufferInfo info;
|
||||
ssize_t o = AMediaCodec_dequeueOutputBuffer(codec_, &info, 20000);
|
||||
if (o == AMEDIACODEC_INFO_TRY_AGAIN_LATER || o == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED ||
|
||||
o == AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED)
|
||||
continue;
|
||||
if (o < 0) return false;
|
||||
size_t size = 0;
|
||||
uint8_t *data = AMediaCodec_getOutputBuffer(codec_, (size_t)o, &size);
|
||||
const uint8_t *payload = data ? data + info.offset : nullptr;
|
||||
size_t length = data ? (size_t)info.size : 0;
|
||||
if (info.flags & kBufferFlagCodecConfig) {
|
||||
header_.assign(payload, payload + length);
|
||||
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
|
||||
continue;
|
||||
}
|
||||
*key = (info.flags & kBufferFlagKeyFrame) != 0;
|
||||
bool prepend = *key && !header_.empty() &&
|
||||
(length < header_.size() || memcmp(payload, header_.data(), header_.size()) != 0);
|
||||
size_t total = length + (prepend ? header_.size() : 0);
|
||||
bool fits = payload && total <= out.size;
|
||||
if (fits) {
|
||||
if (prepend) memcpy(out.data, header_.data(), header_.size());
|
||||
memcpy(out.data + (prepend ? header_.size() : 0), payload, length);
|
||||
*bytes = (uint32_t)total;
|
||||
}
|
||||
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
|
||||
return fits;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int fd_;
|
||||
AMediaCodec *codec_ = nullptr;
|
||||
venc_config config_ = {};
|
||||
int32_t dst_stride_ = 0, dst_rows_ = 0;
|
||||
std::vector<Mapping> inputs_, outputs_;
|
||||
std::vector<uint8_t> header_;
|
||||
};
|
||||
|
||||
std::atomic<int> g_listener{-1};
|
||||
|
||||
void serve(int listener) {
|
||||
for (;;) {
|
||||
int fd = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC);
|
||||
if (fd < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return;
|
||||
}
|
||||
std::thread([fd] {
|
||||
Session s(fd);
|
||||
s.run();
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) {
|
||||
if (g_listener.load() >= 0) return JNI_TRUE;
|
||||
const char *path = env->GetStringUTFChars(jpath, nullptr);
|
||||
sockaddr_un addr = {};
|
||||
addr.sun_family = AF_UNIX;
|
||||
bool fits = strlen(path) < sizeof addr.sun_path;
|
||||
if (fits) strcpy(addr.sun_path, path);
|
||||
int fd = fits ? socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0) : -1;
|
||||
if (fd >= 0) {
|
||||
unlink(path);
|
||||
if (bind(fd, (sockaddr *)&addr, sizeof addr) != 0 || listen(fd, 4) != 0) {
|
||||
LOG("could not listen on %s: %s", path, strerror(errno));
|
||||
close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
}
|
||||
env->ReleaseStringUTFChars(jpath, path);
|
||||
if (fd < 0) return JNI_FALSE;
|
||||
g_listener.store(fd);
|
||||
std::thread(serve, fd).detach();
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStop(JNIEnv *, jobject) {
|
||||
int fd = g_listener.exchange(-1);
|
||||
if (fd >= 0) {
|
||||
shutdown(fd, SHUT_RDWR);
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
@@ -11,29 +11,46 @@ import java.io.File
|
||||
|
||||
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var deckyInstalled by mutableStateOf<String?>(null)
|
||||
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
|
||||
var deckyLatest by mutableStateOf<DeckyManager.Release?>(null)
|
||||
var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE)
|
||||
private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList())
|
||||
var deckyChecking by mutableStateOf(false)
|
||||
var deckyStage by mutableStateOf<String?>(null)
|
||||
var deckyPercent by mutableIntStateOf(-1)
|
||||
var deckyDownloadingBinary by mutableStateOf(false)
|
||||
var deckySupervisor by mutableStateOf(false)
|
||||
var deckyPendingZip by mutableStateOf<File?>(null)
|
||||
var deckyReleaseRequest = 0
|
||||
|
||||
fun refreshDecky() {
|
||||
val request = ++deckyReleaseRequest
|
||||
deckyBranch = DeckyManager.branch(activity)
|
||||
deckyChecking = true
|
||||
Thread({
|
||||
val channels = runCatching { DeckyManager.releaseChannels(activity) }
|
||||
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
|
||||
ui.post {
|
||||
if (request == deckyReleaseRequest) {
|
||||
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
|
||||
deckyChannels = channels
|
||||
deckyLatest = DeckyManager.latest(channels, deckyBranch)
|
||||
deckyChecking = false
|
||||
}
|
||||
}
|
||||
}, "decky-releases").start()
|
||||
}
|
||||
|
||||
fun switchDeckyBranch(branch: Int) {
|
||||
if (branch == deckyBranch || deckyStage != null || SessionState.running) return
|
||||
val saved = runCatching { DeckyManager.setBranch(activity, branch) }
|
||||
if (saved.isFailure) {
|
||||
android.widget.Toast.makeText(activity, R.string.decky_channel_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
deckyBranch = branch
|
||||
deckyLatest = DeckyManager.latest(deckyChannels, branch)
|
||||
refreshDecky()
|
||||
}
|
||||
|
||||
fun installDecky(release: DeckyManager.Release) {
|
||||
if (deckyStage != null || SessionState.running) return
|
||||
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
|
||||
@@ -49,6 +66,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
}
|
||||
|
||||
fun importPluginZip(archive: File) {
|
||||
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
|
||||
deckyPendingZip = archive
|
||||
}
|
||||
|
||||
fun cancelPluginZip() {
|
||||
deckyPendingZip = null
|
||||
}
|
||||
|
||||
fun confirmPluginZip() {
|
||||
val archive = deckyPendingZip ?: return
|
||||
deckyPendingZip = null
|
||||
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
|
||||
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
|
||||
Thread({
|
||||
|
||||
@@ -119,6 +119,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var returnAfterGame by mutableStateOf(true)
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
@@ -160,7 +161,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var picturePosition by mutableStateOf("center")
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
|
||||
private var downloadMirror by mutableStateOf("")
|
||||
@@ -768,6 +769,9 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
|
||||
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
|
||||
)
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
showPerformance -> { { PerformanceHost() } }
|
||||
showComponents -> { { ComponentsHost() } }
|
||||
@@ -1631,6 +1635,7 @@ class MainActivity : ComponentActivity() {
|
||||
forceSsbs = forceSsbs,
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
|
||||
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
|
||||
steamRepairQueued = steamRepairQueued,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
@@ -1646,7 +1651,8 @@ class MainActivity : ComponentActivity() {
|
||||
wifiDiscoveryAsked = wifiDiscoveryAsked,
|
||||
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null,
|
||||
deckyBranch = decky.deckyBranch,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
deckyDownloadingBinary = decky.deckyDownloadingBinary,
|
||||
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
|
||||
@@ -1716,6 +1722,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
|
||||
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
|
||||
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
|
||||
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
|
||||
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
|
||||
onRunSteamAtStartup = { on ->
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
@@ -1729,6 +1736,7 @@ class MainActivity : ComponentActivity() {
|
||||
onBootMovieChanged = { loadBootMovie() },
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
onDeckyBranch = { branch -> decky.switchDeckyBranch(branch) },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
|
||||
onDeckyUninstall = {
|
||||
DeckyManager.uninstall(this, wipeData = false)
|
||||
@@ -1881,6 +1889,7 @@ class MainActivity : ComponentActivity() {
|
||||
steamClientArch = SessionPrefs.steamClientArch(this)
|
||||
steamRepairQueued = SteamRepair.queued(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
returnAfterGame = SessionPrefs.returnAfterGame(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
loadBootMovie()
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -111,7 +112,7 @@ import com.droiddeck.launcher.ui.HudText
|
||||
import com.droiddeck.launcher.ui.LoadingOverlay
|
||||
import com.droiddeck.launcher.ui.SessionDrawer
|
||||
import com.droiddeck.launcher.ui.SessionPausedOverlay
|
||||
import com.droiddeck.launcher.ui.SecondScreenWindow
|
||||
import com.droiddeck.launcher.ui.SecondScreenPresentation
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
@@ -306,6 +307,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
|
||||
private var achievementsOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var hudFps by mutableStateOf(true)
|
||||
private var hudBattery by mutableStateOf(true)
|
||||
private var hudGraph by mutableStateOf(true)
|
||||
private var hudColor by mutableStateOf(SessionPrefs.HUD_COLOR_MINT)
|
||||
private var hudOpacity by mutableStateOf(SessionPrefs.HUD_OPACITY_DEFAULT)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
private var effects by mutableStateOf(ScreenEffects.OFF)
|
||||
@@ -361,7 +367,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
|
||||
private var started = false
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private var secondScreenPresentation: SecondScreenWindow? = null
|
||||
private var secondScreenPresentation: SecondScreenPresentation? = null
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
|
||||
@@ -469,6 +475,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this))
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
@@ -638,8 +645,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model)
|
||||
if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, hud.graph, hudColor, hudOpacity)
|
||||
val phonePanel = glassesOutputActive && !loading.visible && !drawerOpen && !pcKeyboardOpen
|
||||
if (phonePanel) GlassesPhonePanel(glasses, steamVrStatus == SteamVr.Status.RUNNING, headTracking)
|
||||
if (trackingRecovery != null && !drawerOpen && !pcKeyboardOpen && !loading.visible) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { com.droiddeck.launcher.ui.TrackingRecoveryView(it) },
|
||||
@@ -647,14 +655,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
update = { it.update(trackingRecovery, !glassesOutputActive && glasses.usable && glasses.sbs) },
|
||||
)
|
||||
}
|
||||
if (GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) &&
|
||||
if (!phonePanel && GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) &&
|
||||
headTracking && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesRecenterButton(
|
||||
controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100,
|
||||
onRecenterJoyCons = if (steamVrStatus == SteamVr.Status.RUNNING &&
|
||||
glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
|
||||
joyConVr?.let { { it.recenter() } } else null,
|
||||
) { headTracker?.recenter() }
|
||||
if (glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot,
|
||||
if (!phonePanel && glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot,
|
||||
controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100) {
|
||||
drawerPage = com.droiddeck.launcher.ui.SessionDrawerPage.GLASSES
|
||||
drawerOpen = true
|
||||
@@ -752,6 +760,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
isHomeApp = isHomeApp,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
hudFps = hudFps,
|
||||
hudBattery = hudBattery,
|
||||
hudGraph = hudGraph,
|
||||
hudColor = hudColor,
|
||||
hudOpacity = hudOpacity,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
effects = effects,
|
||||
cardboard = cardboard,
|
||||
@@ -779,6 +792,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onHudFps = { on -> SessionPrefs.setHudShowFps(this@SessionActivity, on); hudFps = on; hud.refresh() },
|
||||
onHudBattery = { on -> SessionPrefs.setHudShowBattery(this@SessionActivity, on); hudBattery = on; hud.refresh() },
|
||||
onHudGraph = { on -> SessionPrefs.setHudShowGraph(this@SessionActivity, on); hudGraph = on; hud.refresh() },
|
||||
onHudColor = { color -> SessionPrefs.setHudColor(this@SessionActivity, color); hudColor = color },
|
||||
onHudOpacity = { percent -> SessionPrefs.setHudOpacity(this@SessionActivity, percent); hudOpacity = percent },
|
||||
onUpscaler = { m ->
|
||||
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
|
||||
WaylandCompositor.nativeSetUpscaler(m)
|
||||
@@ -998,6 +1016,54 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, 90)
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun GlassesPhonePanel(glasses: com.droiddeck.launcher.glasses.GlassesState, vr: Boolean, headTracking: Boolean) {
|
||||
val battery by androidx.compose.runtime.produceState<String?>(null) {
|
||||
while (true) {
|
||||
val b = registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED))
|
||||
val level = b?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val scale = b?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, 100) ?: 100
|
||||
val tenths = b?.getIntExtra(android.os.BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1
|
||||
value = if (level < 0) null else getString(R.string.glasses_phone_battery, level * 100 / scale.coerceAtLeast(1), tenths / 10)
|
||||
kotlinx.coroutines.delay(30_000)
|
||||
}
|
||||
}
|
||||
val pads = com.droiddeck.launcher.input.VrControllers.settings.collectAsState().value
|
||||
com.droiddeck.launcher.ui.GlassesPhonePanel(glasses.model, battery, com.droiddeck.launcher.ui.GlassesPhoneActions(
|
||||
onRecenter = if (headTracking) ({ headTracker?.recenter() }) else null,
|
||||
onRecenterJoyCons = if (vr && glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
|
||||
joyConVr?.let { { it.recenter() } } else null,
|
||||
onSteamVrMenu = if (vr && SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onSwitchSticks = if (vr && pads.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ({ switchVrSticks() }) else null,
|
||||
controllers = getString(when (pads.emulate) {
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_off
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.INDEX -> R.string.vr_controllers_index
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.TOUCH -> R.string.vr_controllers_touch
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_vive
|
||||
}),
|
||||
onNextControllers = if (vr) ({
|
||||
val all = com.droiddeck.launcher.input.VrControllers.Emulate.entries
|
||||
com.droiddeck.launcher.input.VrControllers.setEmulate(this, all[(pads.emulate.ordinal + 1) % all.size])
|
||||
}) else null,
|
||||
onKeyboard = { keyboard?.toggle() },
|
||||
onSessionMenu = { drawerOpen = true },
|
||||
onStopSteamVr = if (vr) ({ steamVrAction("stop") }) else null,
|
||||
brightness = glasses.brightness,
|
||||
brightnessMax = glasses.brightnessMax,
|
||||
onBrightness = if (glasses.canControl && glasses.hardwareControls) Glasses::setBrightness else null,
|
||||
))
|
||||
}
|
||||
|
||||
/** The SteamVR driver switches pad sticks between hands and game on both stick clicks together. */
|
||||
private fun switchVrSticks() {
|
||||
val chord = { down: Boolean -> padBridge?.applyTouch { state ->
|
||||
state.press(com.droiddeck.launcher.input.PadState.L3, down)
|
||||
state.press(com.droiddeck.launcher.input.PadState.R3, down)
|
||||
} }
|
||||
chord(true)
|
||||
uiHandler.postDelayed({ chord(false) }, 120)
|
||||
}
|
||||
|
||||
private fun triggerSteamQam() {
|
||||
drawerOpen = false
|
||||
padBridge?.triggerQam()
|
||||
@@ -1343,6 +1409,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
picturePosition = SessionPrefs.picturePosition(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
hudFps = SessionPrefs.hudShowFps(this)
|
||||
hudBattery = SessionPrefs.hudShowBattery(this)
|
||||
hudGraph = SessionPrefs.hudShowGraph(this)
|
||||
hudColor = SessionPrefs.hudColor(this)
|
||||
hudOpacity = SessionPrefs.hudOpacity(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
effects = SessionPrefs.screenEffects(this)
|
||||
@@ -1525,10 +1596,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
|
||||
FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
|
||||
com.droiddeck.launcher.input.VrControllers.publish(this)
|
||||
|
||||
val turnip = TurnipDriver(this)
|
||||
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "surface.driver-setup")
|
||||
if (!CompositorHost.isStarted) CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
|
||||
if (!CompositorHost.isStarted) {
|
||||
CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
|
||||
com.droiddeck.launcher.gpu.AutoDriverTrial.displayStarting(this, SessionPrefs.androidDriver(this))
|
||||
}
|
||||
val driverId = if (CompositorHost.isStarted) null else turnip.install()
|
||||
|
||||
val previous = SessionState.outputSize
|
||||
@@ -2336,7 +2411,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
var presentation = secondScreenPresentation
|
||||
if (presentation?.display?.displayId != target.displayId) {
|
||||
presentation?.closeControls()
|
||||
presentation = SecondScreenWindow(
|
||||
presentation = SecondScreenPresentation(
|
||||
this, target,
|
||||
sendPointer = ::moveSecondScreenPointer,
|
||||
sendButton = { button, pressed ->
|
||||
@@ -2676,7 +2751,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
if (pipUi || unfoldedPanel != null || pcKeyboardOpen) {
|
||||
if (pipUi || unfoldedPanel != null || pcKeyboardOpen ||
|
||||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
|
||||
@@ -19,7 +19,10 @@ import java.io.FileInputStream
|
||||
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
|
||||
* was made with install, whichever host serves them.
|
||||
*/
|
||||
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
|
||||
internal class AddonPack(
|
||||
val name: String, private val sha256: String, val size: Long, private val required: List<String>,
|
||||
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
|
||||
) {
|
||||
sealed interface State {
|
||||
data object Missing : State
|
||||
data class Downloading(val progress: Float) : State
|
||||
@@ -40,10 +43,20 @@ internal class AddonPack(val name: String, private val sha256: String, val size:
|
||||
|
||||
private fun root(context: Context) = File(context.filesDir, "addons/$name")
|
||||
/** App-private and written only after the archive matched the pinned digest. */
|
||||
fun dir(context: Context) = File(root(context), sha256)
|
||||
fun dir(context: Context) = File(root(context), version(context))
|
||||
fun file(context: Context, relative: String) = File(dir(context), relative)
|
||||
|
||||
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
|
||||
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
|
||||
|
||||
// A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed.
|
||||
private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
|
||||
|
||||
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
|
||||
.filter { dir ->
|
||||
dir.isDirectory && runCatching { File(dir, COMPLETE).readText().trim() }.getOrNull() == dir.name &&
|
||||
required.all { File(dir, it).isFile }
|
||||
}
|
||||
.maxByOrNull { it.lastModified() }?.name
|
||||
|
||||
fun refresh(context: Context): State {
|
||||
val now = mutableState.value
|
||||
@@ -86,7 +99,7 @@ internal class AddonPack(val name: String, private val sha256: String, val size:
|
||||
|
||||
private fun install(context: Context): Boolean {
|
||||
if (!available) {
|
||||
Log.w(TAG, "$name: this build has no pack")
|
||||
Log.w(TAG, if (reuseDeviceCopy) "$name: this build has no pack and none is installed" else "$name: this build has no pack")
|
||||
return false
|
||||
}
|
||||
val root = root(context).apply { mkdirs() }
|
||||
|
||||
@@ -252,6 +252,7 @@ object DeviceReport {
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
|
||||
k("Return to launcher after a game", SessionPrefs.returnAfterGame(context))
|
||||
k("Microphone", SessionPrefs.micEnabled(context))
|
||||
k("On-screen controls", SessionPrefs.oscMode(context))
|
||||
k("Touch mode", SessionPrefs.touchMode(context))
|
||||
|
||||
@@ -323,8 +323,8 @@ object AddedGameArt {
|
||||
if (dst.isFile) continue
|
||||
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
|
||||
val tmp = File(cache, "$localName.tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (tmp.renameTo(dst)) { got = true; File(cache, "$localName.src").writeText(STEAM) }
|
||||
if (runCatching { tmp.writeBytes(bytes) }.isFailure) { tmp.delete(); continue }
|
||||
if (tmp.renameTo(dst)) { got = true; runCatching { File(cache, "$localName.src").writeText(STEAM) } }
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
@@ -63,7 +63,8 @@ object CoverArt {
|
||||
missFile(context, rom).delete()
|
||||
found = true
|
||||
} else {
|
||||
missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") }
|
||||
runCatching { missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") } }
|
||||
.onFailure { Log.w(TAG, "${rom.name}: miss marker not written: $it") }
|
||||
}
|
||||
}
|
||||
return found
|
||||
|
||||
@@ -50,6 +50,7 @@ data class GlassesState(
|
||||
val noSignal: Boolean = false,
|
||||
val sbsSupport: Int = SBS_UNKNOWN,
|
||||
val sbs90: Boolean = false,
|
||||
val rates2d: List<Int> = listOf(60, 90, 120),
|
||||
val experimental: Boolean = false,
|
||||
val supportsScreen3dof: Boolean = false,
|
||||
val supports30Hz: Boolean = false,
|
||||
@@ -613,12 +614,13 @@ object Glasses {
|
||||
switching3d = modeState.pendingStereo,
|
||||
brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8,
|
||||
sunblock = if (m === XrealModel) it.sunblock else _settings.value.sunblock ?: it.sunblock,
|
||||
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel,
|
||||
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel || m is RokidModel,
|
||||
hasSunblock = m?.hasSunblock == true,
|
||||
diagonalFovDeg = m?.diagonalFovDeg ?: 46f,
|
||||
tracking = it.tracking && link != null,
|
||||
noSignal = noSignal,
|
||||
sbs90 = m?.sbs90 == true,
|
||||
rates2d = m?.rates2d ?: listOf(60, 90, 120),
|
||||
experimental = m === XrealModel,
|
||||
supportsScreen3dof = link?.hasImu == true,
|
||||
supports30Hz = display?.let(::supports30Hz) == true,
|
||||
@@ -634,7 +636,8 @@ object Glasses {
|
||||
scheduleModeCheck()
|
||||
}
|
||||
|
||||
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID
|
||||
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) ||
|
||||
vid == XrealModel.VENDOR_ID || vid == RokidModel.VENDOR_ID
|
||||
|
||||
internal fun supports30Hz(display: Display): Boolean {
|
||||
val current = display.mode
|
||||
@@ -654,11 +657,13 @@ object Glasses {
|
||||
?: VitureProtocol.model(device.productId, name)
|
||||
}
|
||||
else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel
|
||||
else RayNeoModel.model(device.vendorId, device.productId, name)
|
||||
else RokidModel.model(device.vendorId, device.productId, name)
|
||||
?: RayNeoModel.model(device.vendorId, device.productId, name)
|
||||
}
|
||||
|
||||
internal fun isGlassesDisplayName(name: String) =
|
||||
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name)
|
||||
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) ||
|
||||
RokidModel.isDisplay(name)
|
||||
|
||||
private fun findDisplay(usbPresent: Boolean): Display? {
|
||||
return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent)
|
||||
@@ -723,6 +728,8 @@ object Glasses {
|
||||
} else VitureLink.open(usb, device, linkListener(generation))
|
||||
} else if (model === XrealModel) {
|
||||
XrealLink.open(app, xrealListener(generation))
|
||||
} else if (model is RokidModel) {
|
||||
RokidLink.open(usb, device, rokidListener(generation))
|
||||
} else {
|
||||
val baseKey = calibrationKey!!
|
||||
val gtUsb = RayNeoProtocol.isGt(device.vendorId, device.productId)
|
||||
@@ -790,6 +797,33 @@ object Glasses {
|
||||
}
|
||||
}
|
||||
|
||||
private fun rokidListener(generation: Long) = object : RokidLink.Listener {
|
||||
override fun onReady() = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
publish { it.copy(connected = true, controlError = null) }
|
||||
updateImu()
|
||||
applyMode(force = false)
|
||||
}
|
||||
override fun onValue(message: Int, value: Int) = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
when (message) {
|
||||
VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value
|
||||
VitureProtocol.MSG_BRIGHTNESS -> publish { it.copy(brightness = value) }
|
||||
}
|
||||
}
|
||||
override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) {
|
||||
if (generation != linkGeneration) return
|
||||
imuPackets++
|
||||
probe?.invoke(raw, pose)
|
||||
if (simulating.get() == null) deliver(pose, timeNs)
|
||||
}
|
||||
override fun onLost() = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
closeLink()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sdkListener(generation: Long) = object : VitureSdkLink.Listener {
|
||||
override fun onSpatial(pose: FloatArray, timeNs: Long, valid: Boolean) {
|
||||
if (generation == linkGeneration) SpatialTracking.accept(pose, timeNs, generation, valid)
|
||||
|
||||
@@ -8,6 +8,7 @@ interface GlassesModel {
|
||||
val volumeMax: Int
|
||||
val diagonalFovDeg: Float
|
||||
val sbs90: Boolean
|
||||
val rates2d: List<Int> get() = listOf(60, 90, 120)
|
||||
val hasSunblock: Boolean get() = true
|
||||
val resolutions: List<GlassesResolution> get() = emptyList()
|
||||
fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = mode(hz, sbs)
|
||||
|
||||
@@ -12,8 +12,8 @@ internal object GlassesRendering {
|
||||
fun refreshRates(settings: GlassesSettings, state: GlassesState): List<Int> = buildList {
|
||||
if (native30HzAvailable(settings, state)) add(30)
|
||||
add(60)
|
||||
if (!state.sbs || state.sbs90) add(90)
|
||||
if (!state.sbs) add(120)
|
||||
if (state.sbs) { if (state.sbs90) add(90) }
|
||||
else addAll(state.rates2d.filter { it > 60 })
|
||||
}
|
||||
|
||||
fun outputHz(settings: GlassesSettings, state: GlassesState): Int {
|
||||
@@ -21,6 +21,7 @@ internal object GlassesRendering {
|
||||
return when {
|
||||
requested == 30 -> if (native30HzAvailable(settings, state)) 30 else 60
|
||||
state.sbs && !state.sbs90 -> 60
|
||||
!state.sbs && requested !in state.rates2d -> state.rates2d.filter { it <= requested }.maxOrNull() ?: 60
|
||||
else -> requested
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
|
||||
/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */
|
||||
internal class RokidLink private constructor(
|
||||
private val connection: UsbDeviceConnection,
|
||||
private val usbInterface: UsbInterface,
|
||||
private val endpoint: UsbEndpoint,
|
||||
private val listener: Listener,
|
||||
) : GlassesLink {
|
||||
interface Listener {
|
||||
fun onReady()
|
||||
fun onValue(message: Int, value: Int)
|
||||
fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long)
|
||||
fun onLost()
|
||||
}
|
||||
|
||||
@Volatile private var running = true
|
||||
@Volatile override var ready = false
|
||||
private set
|
||||
@Volatile private var tracking = false
|
||||
private val controlLock = Any()
|
||||
private lateinit var worker: Thread
|
||||
override val hasImu = true
|
||||
|
||||
override fun send(msgId: Int, payload: ByteArray): Boolean {
|
||||
if (!running) return false
|
||||
val value = payload.firstOrNull()?.toInt()?.and(0xff) ?: -1
|
||||
return when (msgId) {
|
||||
VitureProtocol.MSG_IMU -> (value in 0..1).also { if (it) tracking = value == 1 }
|
||||
VitureProtocol.MSG_GET_DISPLAY -> display()?.let { listener.onValue(msgId, it) } != null
|
||||
VitureProtocol.MSG_DISPLAY -> value in RokidModel.MODES &&
|
||||
control(RokidModel.REQUEST_SET_DISPLAY, value, RokidModel.INDEX_DISPLAY) &&
|
||||
true.also { listener.onValue(VitureProtocol.MSG_GET_DISPLAY, value) }
|
||||
VitureProtocol.MSG_GET_BRIGHTNESS -> read(RokidModel.REQUEST_GET_BRIGHTNESS, RokidModel.INDEX_BRIGHTNESS)
|
||||
?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null
|
||||
VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS &&
|
||||
control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS)
|
||||
else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control.
|
||||
}
|
||||
}
|
||||
|
||||
private fun display(): Int? = read(RokidModel.REQUEST_GET_DISPLAY, RokidModel.INDEX_DISPLAY)
|
||||
|
||||
private fun read(request: Int, index: Int): Int? = synchronized(controlLock) {
|
||||
if (!running) return null
|
||||
val buffer = ByteArray(64)
|
||||
val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS)
|
||||
// Display mode is reported in the second byte, brightness in the first.
|
||||
val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0
|
||||
if (count > offset) buffer[offset].toInt() and 0xff else null
|
||||
}
|
||||
|
||||
private fun control(request: Int, value: Int, index: Int): Boolean = synchronized(controlLock) {
|
||||
running && connection.controlTransfer(VENDOR_OUT, request, value, index, byteArrayOf(1), 1, TIMEOUT_MS) >= 0
|
||||
}
|
||||
|
||||
private fun run() {
|
||||
val buffer = ByteArray(maxOf(endpoint.maxPacketSize, 64))
|
||||
val parser = RokidProtocol.Parser()
|
||||
var fusion = ImuFusion()
|
||||
var wasTracking = false
|
||||
var brightness = -1
|
||||
var lastSensor = SystemClock.elapsedRealtime()
|
||||
try {
|
||||
ready = true
|
||||
listener.onReady()
|
||||
while (running) {
|
||||
val count = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val sample = if (count > 0) parser.parse(buffer, count) else null
|
||||
if (sample == null) {
|
||||
if (now - lastSensor > 3000) error("Rokid IMU stream stalled")
|
||||
continue
|
||||
}
|
||||
lastSensor = now
|
||||
sample.brightness?.takeIf { it != brightness }?.let {
|
||||
brightness = it
|
||||
listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it))
|
||||
}
|
||||
val enabled = tracking
|
||||
if (enabled && !wasTracking) fusion = ImuFusion()
|
||||
wasTracking = enabled
|
||||
if (!enabled) continue
|
||||
// Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ.
|
||||
fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let {
|
||||
if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (running) Log.w(TAG, "Rokid stream failed", e)
|
||||
} finally {
|
||||
ready = false
|
||||
if (running) listener.onLost()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(controlLock) {
|
||||
if (!running) return
|
||||
running = false
|
||||
}
|
||||
if (Thread.currentThread() !== worker) worker.join(500)
|
||||
runCatching { connection.releaseInterface(usbInterface) }
|
||||
connection.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RokidLink"
|
||||
private const val TIMEOUT_MS = 250
|
||||
private const val VENDOR_IN = UsbConstants.USB_DIR_IN or UsbConstants.USB_TYPE_VENDOR
|
||||
private const val VENDOR_OUT = UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_VENDOR
|
||||
|
||||
internal fun sensorEndpoint(device: UsbDevice): Pair<UsbInterface, UsbEndpoint>? {
|
||||
for (i in 0 until device.interfaceCount) {
|
||||
val iface = device.getInterface(i)
|
||||
if (iface.interfaceClass != UsbConstants.USB_CLASS_HID) continue
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val endpoint = iface.getEndpoint(e)
|
||||
if (endpoint.direction == UsbConstants.USB_DIR_IN &&
|
||||
endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT) return iface to endpoint
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun open(usb: UsbManager, device: UsbDevice, listener: Listener): RokidLink? {
|
||||
val (iface, endpoint) = sensorEndpoint(device) ?: return null
|
||||
val conn = usb.openDevice(device) ?: return null
|
||||
if (!conn.claimInterface(iface, true)) {
|
||||
conn.close()
|
||||
return null
|
||||
}
|
||||
return RokidLink(conn, iface, endpoint, listener).also { link ->
|
||||
link.worker = Thread({ link.run() }, "rokid-imu").apply { isDaemon = true }
|
||||
link.worker.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/** Rokid Air/Max share one USB identity; the product string distinguishes them. */
|
||||
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
|
||||
override val controllable = true
|
||||
override val brightnessMax = BRIGHTNESS_STEPS
|
||||
override val volumeMax = 0
|
||||
override val hasSunblock = false
|
||||
override val diagonalFovDeg = if (max) 50f else 43f
|
||||
override val sbs90 = max
|
||||
override val rates2d = if (max) listOf(60, 120) else listOf(60)
|
||||
override val resolutions = if (max) listOf(GlassesResolution(1920, 1080), GlassesResolution(1920, 1200))
|
||||
else listOf(GlassesResolution(1920, 1080))
|
||||
|
||||
override fun mode(hz: Int, sbs: Boolean): Int = mode(hz, sbs, null)
|
||||
|
||||
override fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = when {
|
||||
!max -> if (sbs) MODE_SBS_1080_60 else MODE_2D_60
|
||||
sbs && resolution?.height == 1080 -> MODE_SBS_1080_60
|
||||
sbs -> if (hz >= 90) MODE_SBS_1200_90 else MODE_SBS_1200_60
|
||||
hz >= 120 -> MODE_2D_120
|
||||
else -> MODE_2D_60
|
||||
}
|
||||
|
||||
override fun modeForStep(step: Int): Int? = null
|
||||
|
||||
override fun equals(other: Any?) = other is RokidModel && other.name == name && other.max == max
|
||||
override fun hashCode() = name.hashCode()
|
||||
|
||||
companion object {
|
||||
const val VENDOR_ID = 0x04d2
|
||||
const val PRODUCT_ID = 0x162f
|
||||
const val BRIGHTNESS_STEPS = 10
|
||||
|
||||
// Vendor control requests, device recipient.
|
||||
const val REQUEST_SET_DISPLAY = 0x01
|
||||
const val REQUEST_GET_DISPLAY = 0x81
|
||||
const val REQUEST_SET_BRIGHTNESS = 0x02
|
||||
const val REQUEST_GET_BRIGHTNESS = 0x82
|
||||
const val INDEX_DISPLAY = 0x01
|
||||
const val INDEX_BRIGHTNESS = 0x02
|
||||
|
||||
const val MODE_2D_60 = 0
|
||||
const val MODE_SBS_1080_60 = 1
|
||||
const val MODE_2D_120 = 3
|
||||
const val MODE_SBS_1200_90 = 4
|
||||
const val MODE_SBS_1200_60 = 5
|
||||
val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60)
|
||||
|
||||
// 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
|
||||
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
|
||||
if (vid != VENDOR_ID || pid != PRODUCT_ID) return null
|
||||
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
|
||||
return RokidModel(product, product.contains("Max", ignoreCase = true))
|
||||
}
|
||||
|
||||
fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true)
|
||||
|
||||
/** Device brightness is 0..100; the shared slider uses ten steps. */
|
||||
fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS)
|
||||
fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10
|
||||
}
|
||||
}
|
||||
|
||||
internal object RokidProtocol {
|
||||
data class Sample(val timeNs: Long, val accel: Vec3, val gyro: Vec3, val brightness: Int?)
|
||||
|
||||
private const val COMBINED = 17
|
||||
private const val SEPARATE = 4
|
||||
private const val SENSOR_ACCEL = 1
|
||||
private const val SENSOR_GYRO = 2
|
||||
|
||||
/** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */
|
||||
class Parser {
|
||||
private var accel: Vec3? = null
|
||||
private var accelTime = -1L
|
||||
private var gyro: Vec3? = null
|
||||
private var gyroTime = -2L
|
||||
|
||||
fun parse(packet: ByteArray, length: Int = packet.size): Sample? {
|
||||
if (length < 64) return null
|
||||
val b = ByteBuffer.wrap(packet, 0, length).order(ByteOrder.LITTLE_ENDIAN)
|
||||
return when (packet[0].toInt() and 0xff) {
|
||||
COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff)
|
||||
SEPARATE -> {
|
||||
val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds.
|
||||
when (packet[1].toInt() and 0xff) {
|
||||
SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time }
|
||||
SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time }
|
||||
else -> return null
|
||||
}
|
||||
if (accelTime != gyroTime) return null
|
||||
Sample(time, accel!!, gyro!!, null).also { accelTime = -1L; gyroTime = -2L }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun vec(b: ByteBuffer, offset: Int) = Vec3(b.getFloat(offset), b.getFloat(offset + 4), b.getFloat(offset + 8))
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,27 @@ import kotlin.math.abs
|
||||
|
||||
/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
|
||||
internal class XrealOrientationFilter {
|
||||
private val fusion = ImuFusion()
|
||||
|
||||
// 1S reports right/down/forward. Rotate 180 degrees about X into the
|
||||
// shared right/up/back basis before either gravity alignment or fusion.
|
||||
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
|
||||
fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs,
|
||||
Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z),
|
||||
Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z))
|
||||
}
|
||||
|
||||
/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */
|
||||
internal class ImuFusion {
|
||||
private var previous = 0L
|
||||
private var rotation = Quaternion.IDENTITY
|
||||
private var initialized = false
|
||||
private var bias = Vec3(0f, 0f, 0f)
|
||||
|
||||
fun update(sample: XrealProtocol.Sample): FloatArray? {
|
||||
if (sample.timeNs <= previous) return null
|
||||
val dt = if (previous == 0L) 0f else (sample.timeNs - previous) * 1e-9f
|
||||
previous = sample.timeNs
|
||||
// 1S reports right/down/forward. Rotate 180 degrees about X into the
|
||||
// shared right/up/back basis before either gravity alignment or fusion.
|
||||
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
|
||||
val accel = Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z)
|
||||
val gyro = Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z)
|
||||
fun update(timeNs: Long, accel: Vec3, gyro: Vec3): FloatArray? {
|
||||
if (timeNs <= previous) return null
|
||||
val dt = if (previous == 0L) 0f else (timeNs - previous) * 1e-9f
|
||||
previous = timeNs
|
||||
val norm = accel.length()
|
||||
val gravityValid = abs(norm - 9.81f) < 1.5f
|
||||
if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion.
|
||||
|
||||
@@ -28,7 +28,7 @@ object AutoDriverTrial {
|
||||
release, p.getString("name", "").orEmpty(),
|
||||
p.getString("display", "").orEmpty(), p.getString("linux", "").orEmpty(),
|
||||
p.getString("previousDisplay", "").orEmpty(), p.getString("previousLinux", "").orEmpty(),
|
||||
p.getInt("starts", 0),
|
||||
maxOf(p.getInt("starts", 0), p.getInt("displayStarts", 0)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ object AutoDriverTrial {
|
||||
.putString("release", release).putString("name", name)
|
||||
.putString("display", display).putString("linux", linux)
|
||||
.putString("previousDisplay", previousDisplay).putString("previousLinux", previousLinux)
|
||||
.putInt("starts", 0)
|
||||
.putInt("starts", 0).putInt("displayStarts", 0)
|
||||
.commit()
|
||||
}
|
||||
|
||||
@@ -48,13 +48,24 @@ object AutoDriverTrial {
|
||||
|
||||
fun bad(context: Context): Set<String> = prefs(context).getStringSet("bad", emptySet()).orEmpty()
|
||||
|
||||
/**
|
||||
* Counted before the compositor loads the trial display driver, so a driver that kills the process during
|
||||
* Vulkan init still counts towards a rollback; sessionStarting runs too late for that.
|
||||
*/
|
||||
fun displayStarting(context: Context, display: String) {
|
||||
val p = prefs(context)
|
||||
if (p.getString("release", null) == null || p.getString("display", "") != display) return
|
||||
val counted = maxOf(p.getInt("displayStarts", 0), p.getInt("starts", 0))
|
||||
p.edit().putInt("displayStarts", counted + 1).commit()
|
||||
}
|
||||
|
||||
fun sessionStarting(context: Context, display: String, linux: String) {
|
||||
val trial = current(context)
|
||||
watching = null
|
||||
if (trial == null || trial.display != display || trial.linux != linux) return
|
||||
app = context.applicationContext
|
||||
watching = trial
|
||||
prefs(context).edit().putInt("starts", trial.starts + 1).commit()
|
||||
prefs(context).edit().putInt("starts", prefs(context).getInt("starts", 0) + 1).commit()
|
||||
}
|
||||
|
||||
fun sessionReady() {
|
||||
|
||||
@@ -99,7 +99,8 @@ public class FakeInputWriter {
|
||||
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
|
||||
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
|
||||
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
|
||||
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
|
||||
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 98;
|
||||
private static final int TRITON_OUTPUT_ACK_OFFSET = TRITON_OFFSET + 100;
|
||||
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
|
||||
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
|
||||
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
|
||||
@@ -229,8 +230,9 @@ public class FakeInputWriter {
|
||||
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
|
||||
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
|
||||
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
|
||||
data.putShort(TRITON_OUTPUT_FEATURE_OFFSET, (short) 0);
|
||||
data.putShort(TRITON_OUTPUT_LENGTH_OFFSET, (short) 0);
|
||||
data.putInt(TRITON_OUTPUT_ACK_OFFSET, 0);
|
||||
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
|
||||
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
|
||||
}
|
||||
@@ -587,14 +589,18 @@ public class FakeInputWriter {
|
||||
nativeStoreFence();
|
||||
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0 || seq <= lastSequence) return null;
|
||||
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
|
||||
int length = ring.getShort(TRITON_OUTPUT_LENGTH_OFFSET) & 0xffff;
|
||||
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
|
||||
byte[] output = new byte[9 + length];
|
||||
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
|
||||
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
|
||||
output[8] = (byte) (ring.getShort(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
|
||||
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
|
||||
nativeStoreFence();
|
||||
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
|
||||
if (seq != ring.getLong(TRITON_OUTPUT_SEQ_OFFSET)) return null;
|
||||
// Acknowledge the exact report sequence taken so a concurrent timeout overwrite cannot lose its length.
|
||||
ring.putInt(TRITON_OUTPUT_ACK_OFFSET, (int) seq);
|
||||
nativeStoreFence();
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class PadBridge {
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private volatile boolean selectSteam;
|
||||
private volatile boolean builtinPad = true;
|
||||
private TouchRoute touchRoute;
|
||||
private final PadState routedTouch = new PadState();
|
||||
private boolean systemGuidePressed;
|
||||
@@ -110,6 +111,25 @@ public final class PadBridge {
|
||||
excluded = devices;
|
||||
}
|
||||
|
||||
public synchronized void setBuiltinPad(boolean on) {
|
||||
boolean changed = builtinPad != on;
|
||||
builtinPad = on;
|
||||
if (changed && !on) resetBuiltin();
|
||||
}
|
||||
|
||||
private void resetBuiltin() {
|
||||
routedTouch.clear();
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamTapPressed = false;
|
||||
qamChordActive = false;
|
||||
chordButtonPressed = false;
|
||||
qamChordGeneration++;
|
||||
chordHandler.removeCallbacksAndMessages(null);
|
||||
state.clear();
|
||||
publish();
|
||||
}
|
||||
|
||||
public synchronized boolean start() {
|
||||
if (!open) {
|
||||
open = writer.open();
|
||||
@@ -373,6 +393,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
if (!builtinPad) return;
|
||||
if (touchRoute != null && touchRoute.active()) {
|
||||
mutation.accept(routedTouch);
|
||||
touchRoute.accept(routedTouch);
|
||||
@@ -412,6 +433,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
|
||||
if (!builtinPad) return;
|
||||
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
|
||||
boolean qamStarted = qamPressed && !systemQamPressed;
|
||||
systemGuidePressed = guidePressed;
|
||||
@@ -443,6 +465,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void triggerQam() {
|
||||
if (!builtinPad) return;
|
||||
if (!nativeQam()) {
|
||||
startChord(PadState.A);
|
||||
return;
|
||||
@@ -461,6 +484,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void triggerSteamKeyboard() {
|
||||
if (!builtinPad) return;
|
||||
startChord(PadState.X);
|
||||
}
|
||||
|
||||
@@ -632,6 +656,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
return;
|
||||
|
||||
@@ -211,7 +211,7 @@ object SteamTouchBindings {
|
||||
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
|
||||
|
||||
fun gameIconsDir(context: Context, appId: Int): File? = try {
|
||||
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
|
||||
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_${SteamTouchConfig.steamId(appId)}.acf")
|
||||
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
|
||||
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -126,7 +126,11 @@ object SteamTouchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
|
||||
private val DEFAULT_SET = setOf(
|
||||
JOYSTICK_LEFT, JOYSTICK_LEFT_BUTTON, JOYSTICK_RIGHT, JOYSTICK_RIGHT_BUTTON,
|
||||
DPAD, A, B, X, Y, SELECT, START,
|
||||
BUMPER_LEFT, BUMPER_RIGHT, TRIGGER_LEFT, TRIGGER_RIGHT,
|
||||
)
|
||||
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
|
||||
|
||||
fun steamId(appId: Int): String = Integer.toUnsignedString(appId)
|
||||
|
||||
@@ -301,9 +301,8 @@ class SteamTouchControls(
|
||||
}
|
||||
}
|
||||
SteamTouchConfig.STEAM -> {
|
||||
if (pressed) canvas.drawCircle(x, y, r, fill)
|
||||
canvas.drawCircle(x, y, r, stroke)
|
||||
drawMark(canvas, x, y, r * 0.5f, if (pressed) Color.argb(alpha, 23, 26, 33) else ink, alpha)
|
||||
if (isSelected) canvas.drawCircle(x, y, r, stroke)
|
||||
drawMark(canvas, x, y, r * 0.5f, if (pressed) accent else ink, alpha)
|
||||
}
|
||||
SteamTouchConfig.SELECT, SteamTouchConfig.START -> {
|
||||
val binding = visuals[e.type]?.firstOrNull()?.binding
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
|
||||
/** What SteamVR sees for the game pad. The driver reads the file once, when SteamVR starts. */
|
||||
object VrControllers {
|
||||
enum class Emulate(val key: String) { OFF("off"), INDEX("index"), TOUCH("touch"), VIVE("vive") }
|
||||
|
||||
data class Settings(val emulate: Emulate = Emulate.INDEX, val sticksMoveHands: Boolean = false)
|
||||
|
||||
const val FILE = "droiddeck-vr-controllers"
|
||||
private const val PREFS = "vr_controllers"
|
||||
|
||||
private val _settings = MutableStateFlow(Settings())
|
||||
val settings: StateFlow<Settings> = _settings.asStateFlow()
|
||||
private var loaded = false
|
||||
|
||||
fun load(context: Context): Settings {
|
||||
if (!loaded) {
|
||||
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
_settings.value = Settings(
|
||||
emulate = Emulate.entries.firstOrNull { it.key == p.getString("emulate", null) } ?: Emulate.INDEX,
|
||||
sticksMoveHands = p.getBoolean("sticksMoveHands", false),
|
||||
)
|
||||
loaded = true
|
||||
}
|
||||
return _settings.value
|
||||
}
|
||||
|
||||
fun setEmulate(context: Context, emulate: Emulate) = save(context, load(context).copy(emulate = emulate))
|
||||
fun setSticksMoveHands(context: Context, on: Boolean) = save(context, load(context).copy(sticksMoveHands = on))
|
||||
|
||||
private fun save(context: Context, settings: Settings) {
|
||||
_settings.value = settings
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("emulate", settings.emulate.key)
|
||||
.putBoolean("sticksMoveHands", settings.sticksMoveHands)
|
||||
.apply()
|
||||
publish(context)
|
||||
}
|
||||
|
||||
fun encode(settings: Settings): String =
|
||||
"emulate=${settings.emulate.key}\nsticks=${if (settings.sticksMoveHands) "hands" else "game"}\n"
|
||||
|
||||
/** Writes into the session's runtime dir, which the guest sees as /run/droiddeck. */
|
||||
fun publish(context: Context) {
|
||||
runCatching {
|
||||
val dir = File(context.filesDir, ".wayland-rt").apply { mkdirs() }
|
||||
val tmp = File(dir, "$FILE.tmp")
|
||||
tmp.writeText(encode(load(context)))
|
||||
tmp.renameTo(File(dir, FILE))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,7 @@ object BwrapSpawner {
|
||||
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
|
||||
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
|
||||
preload(session, rootfs, env).forEach { addBind(it) }
|
||||
zypakShims(binds, root, dir).forEach { addBind(it) }
|
||||
browserSandboxes(env)
|
||||
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
|
||||
}
|
||||
@@ -227,6 +228,20 @@ object BwrapSpawner {
|
||||
|
||||
private const val PRELOAD_DIR = "/run/droiddeck-preload"
|
||||
|
||||
private const val ZYPAK_SHIM = "#!/bin/sh\nprogram=\$1\nshift\nexec \"\$program\" --no-sandbox \"\$@\"\n"
|
||||
|
||||
private fun zypakShims(binds: List<Bind>, root: File, dir: File): List<Bind> {
|
||||
val wrappers = listOf("/app/bin/zypak-wrapper.sh", "/app/bin/zypak-wrapper").filter { guest ->
|
||||
val host = File(hostPath(binds, root.path, guest))
|
||||
host.isFile && !Files.isSymbolicLink(host.toPath())
|
||||
}
|
||||
if (wrappers.isEmpty()) return emptyList()
|
||||
val shim = File(dir, "zypak-wrapper")
|
||||
shim.writeText(ZYPAK_SHIM)
|
||||
chmod(shim, 0x1ed)
|
||||
return wrappers.map { Bind(shim.path, it) }
|
||||
}
|
||||
|
||||
private fun browserSandboxes(env: MutableMap<String, String>) {
|
||||
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
|
||||
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
|
||||
|
||||
@@ -22,6 +22,7 @@ object DeckyManager {
|
||||
private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30"
|
||||
private const val PATH = "root/homebrew/services/PluginLoader"
|
||||
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
|
||||
private const val SETTINGS = "root/homebrew/settings/loader.json"
|
||||
// droiddeck-session reads the marker from the root of the client it runs.
|
||||
private val CEF_REMOTE_DEBUG_MARKERS = listOf(
|
||||
"root/.local/share/Steam/.cef-enable-remote-debugging",
|
||||
@@ -32,7 +33,34 @@ object DeckyManager {
|
||||
private set
|
||||
|
||||
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
|
||||
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
|
||||
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>, val all: List<Release> = stable + prerelease)
|
||||
|
||||
const val BRANCH_STABLE = 0
|
||||
const val BRANCH_PRERELEASE = 1
|
||||
const val BRANCH_TESTING = 2
|
||||
|
||||
private fun settingsFile(context: Context) = File(LinuxRuntime.rootDir(context), SETTINGS)
|
||||
|
||||
fun branch(context: Context): Int {
|
||||
val saved = runCatching { JSONObject(settingsFile(context).readText()).optInt("branch", -1) }.getOrDefault(-1)
|
||||
if (saved >= 0) return saved
|
||||
val version = File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim().orEmpty()
|
||||
return if (version.startsWith("v") && "-pre" in version) BRANCH_PRERELEASE else BRANCH_STABLE
|
||||
}
|
||||
|
||||
fun setBranch(context: Context, branch: Int) {
|
||||
val file = settingsFile(context)
|
||||
val settings = runCatching { JSONObject(file.readText()) }.getOrDefault(JSONObject())
|
||||
settings.put("branch", branch)
|
||||
file.parentFile?.mkdirs()
|
||||
val staged = File(file.parentFile, "${file.name}.tmp")
|
||||
staged.writeText(settings.toString(4))
|
||||
check(staged.renameTo(file)) { "cannot write ${file.path}" }
|
||||
}
|
||||
|
||||
// Mirrors Decky's updater: Stable takes the newest non-prerelease tag, Prerelease the newest tag of either kind.
|
||||
fun latest(channels: ReleaseChannels, branch: Int): Release? =
|
||||
if (branch == BRANCH_STABLE) channels.stable.firstOrNull() else channels.all.firstOrNull()
|
||||
|
||||
fun installed(context: Context): String? {
|
||||
val loader = loader(context)
|
||||
@@ -74,17 +102,13 @@ object DeckyManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun releases(context: Context, prerelease: Boolean): List<Release> {
|
||||
val channels = releaseChannels(context)
|
||||
return if (prerelease) channels.prerelease else channels.stable
|
||||
}
|
||||
|
||||
fun releaseChannels(context: Context): ReleaseChannels {
|
||||
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
|
||||
return try {
|
||||
val array = JSONArray(body)
|
||||
val stable = mutableListOf<Release>()
|
||||
val prerelease = mutableListOf<Release>()
|
||||
val all = mutableListOf<Release>()
|
||||
for (i in 0 until array.length()) {
|
||||
val release = array.getJSONObject(i)
|
||||
if (release.optBoolean("draft", false)) continue
|
||||
@@ -105,9 +129,11 @@ object DeckyManager {
|
||||
val size = binary.optLong("size", 0)
|
||||
if (size <= 0) continue
|
||||
val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62)
|
||||
(if (isPrerelease) prerelease else stable).add(row)
|
||||
if (!row.tag.startsWith("v")) continue
|
||||
all.add(row)
|
||||
(if (isPrerelease || "-pre" in row.tag) prerelease else stable).add(row)
|
||||
}
|
||||
ReleaseChannels(stable, prerelease)
|
||||
ReleaseChannels(stable, prerelease, all)
|
||||
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@ class LinuxNetworkLinkComponent(
|
||||
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
|
||||
}
|
||||
}
|
||||
wifiReceiver = receiver
|
||||
appContext.registerReceiver(receiver, IntentFilter().apply {
|
||||
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
|
||||
addAction(WifiManager.RSSI_CHANGED_ACTION)
|
||||
@@ -112,6 +111,7 @@ class LinuxNetworkLinkComponent(
|
||||
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
|
||||
addAction(LocationManager.MODE_CHANGED_ACTION)
|
||||
})
|
||||
wifiReceiver = receiver
|
||||
scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified()
|
||||
scanHandler.post(scanRequests)
|
||||
requestWifiScan()
|
||||
@@ -119,7 +119,13 @@ class LinuxNetworkLinkComponent(
|
||||
|
||||
override fun stop() {
|
||||
scanHandler.removeCallbacks(scanRequests)
|
||||
wifiReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
wifiReceiver?.let { receiver ->
|
||||
try {
|
||||
appContext.unregisterReceiver(receiver)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, "Wi-Fi receiver was already gone", e)
|
||||
}
|
||||
}
|
||||
wifiReceiver = null
|
||||
val registered = synchronized(lock) { callback.also { callback = null } } ?: return
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,8 @@ import java.io.FileOutputStream
|
||||
object ComponentsManager {
|
||||
private const val TAG = "Components"
|
||||
private const val NIGHTLIES = "The412Banner/Nightlies"
|
||||
private const val DD_COMPONENTS = "Droid-Deck/DroidDeck-Components"
|
||||
internal const val DD_NIGHTLY = "linux-nightly"
|
||||
private val RELEASES = linkedMapOf(
|
||||
"FexCore-Linux" to "fex",
|
||||
"Dxvk-Linux" to "dxvk",
|
||||
@@ -32,6 +34,10 @@ object ComponentsManager {
|
||||
"Dxvk-gplasync-Linux" to "dxvk",
|
||||
"Vkd3d-proton-Linux" to "vkd3d",
|
||||
)
|
||||
// DD_NIGHTLY holds every component in one release; the file name says which.
|
||||
private val NIGHTLY_PREFIXES = linkedMapOf("DD-dxvk-" to "dxvk", "DD-vkd3d-proton-" to "vkd3d", "DD-FEXCore-" to "fex")
|
||||
private val SOURCES = RELEASES.keys.map { NIGHTLIES to it } + (DD_COMPONENTS to DD_NIGHTLY)
|
||||
private val DOWNLOAD_REPOS = listOf(NIGHTLIES, DD_COMPONENTS)
|
||||
private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d")
|
||||
private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux")
|
||||
val COMPONENTS = listOf("fex", "dxvk", "vkd3d")
|
||||
@@ -815,14 +821,15 @@ object ComponentsManager {
|
||||
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
if (!force) return Catalog(emptyList(), 0)
|
||||
val items = JSONArray()
|
||||
for ((tag, comp) in RELEASES) {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue
|
||||
for ((repo, tag) in SOURCES) {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
|
||||
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name")
|
||||
if (!name.endsWith(".wcp")) continue
|
||||
if (!Hashes.isGithubSha256(a.optString("digest"))) continue
|
||||
val comp = assetComp(tag, name) ?: continue
|
||||
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
|
||||
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
|
||||
}
|
||||
@@ -837,10 +844,32 @@ 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<CatalogItem> { it.comp }.then(newestFirst { it.file }))
|
||||
}.sortedWith(CATALOG_ORDER)
|
||||
return Catalog(items, o.optLong("fetchedAt"))
|
||||
}
|
||||
|
||||
internal fun assetComp(release: String, name: String): String? =
|
||||
RELEASES[release] ?: if (release == DD_NIGHTLY) NIGHTLY_PREFIXES.entries.firstOrNull { name.startsWith(it.key) }?.value else null
|
||||
|
||||
// Per component: the stable packages newest first, then the nightlies.
|
||||
internal val CATALOG_ORDER: Comparator<CatalogItem> =
|
||||
compareBy<CatalogItem> { it.comp }.thenBy { it.release == DD_NIGHTLY }.then(newestFirst { it.file })
|
||||
|
||||
// A nightly DD-<flavor>-<version>[+N]-nightly.wcp pairs with the newest stable <flavor>-<version>[-<rev>]-linux.wcp
|
||||
// in the same list, so the stable row can offer it behind a Stable/Nightly switch. Unpaired nightlies keep their own row.
|
||||
internal fun nightlyPairs(items: List<CatalogItem>): Map<CatalogItem, CatalogItem> {
|
||||
val stable = items.filter { it.release != DD_NIGHTLY }
|
||||
return items.filter { it.release == DD_NIGHTLY }.mapNotNull { n ->
|
||||
val base = n.file.removePrefix("DD-").removeSuffix("-nightly.wcp").substringBefore('+')
|
||||
val rev = Regex(Regex.escape(base) + "(-\\d+)?-linux\\.wcp")
|
||||
stable.filter { it.comp == n.comp && rev.matches(it.file) }
|
||||
.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a.file, b.file) }?.let { it to n }
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, ns) -> ns.maxWith { a, b -> ProtonExtras.compareVersions(a.file, b.file) } }
|
||||
}
|
||||
|
||||
internal fun allowedDownload(url: String) = DOWNLOAD_REPOS.any { url.startsWith("https://github.com/$it/releases/download/") }
|
||||
|
||||
private fun <T> newestFirst(of: (T) -> String): Comparator<T> =
|
||||
Comparator { a, b -> ProtonExtras.compareVersions(of(b), of(a)) }
|
||||
|
||||
@@ -925,11 +954,13 @@ object ComponentsManager {
|
||||
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
|
||||
|
||||
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { context.getString(R.string.cmgr_nightlies_only) }
|
||||
require(allowedDownload(item.url)) { context.getString(R.string.cmgr_nightlies_only) }
|
||||
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
|
||||
val dest = File(packagesDir(context), safeName(item.file))
|
||||
val part = File(dest.parentFile, dest.name + ".part")
|
||||
check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
|
||||
check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) {
|
||||
context.getString(if (item.release == DD_NIGHTLY) R.string.cmgr_nightly_replaced else R.string.user_apps_download_failed)
|
||||
}
|
||||
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
|
||||
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
|
||||
synchronized(lock) { part.renameTo(dest) }
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -15,21 +18,35 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class PerfHud(context: Context) {
|
||||
var text by mutableStateOf("")
|
||||
private set
|
||||
var graph by mutableStateOf(FloatArray(0))
|
||||
private set
|
||||
|
||||
private val context = context.applicationContext
|
||||
private val frames = AtomicInteger()
|
||||
private var lastTick = 0L
|
||||
private var lastFps = 0f
|
||||
private var running = false
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val intervals = FloatArray(GRAPH)
|
||||
private val graphWrite = AtomicInteger()
|
||||
private val lastFrameNs = AtomicLong()
|
||||
|
||||
@Volatile private var showFps = true
|
||||
@Volatile private var showBattery = true
|
||||
@Volatile private var showGraph = true
|
||||
|
||||
private val enabled: Boolean
|
||||
get() = SessionPrefs.hudEnabled(context)
|
||||
&& !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
|
||||
|
||||
private val anyPart: Boolean
|
||||
get() = showFps || showBattery || showGraph
|
||||
|
||||
var onPresentingWindowChanged: (() -> Unit)? = null
|
||||
private var lastWindow: String? = null
|
||||
|
||||
@@ -40,7 +57,17 @@ class PerfHud(context: Context) {
|
||||
handler.post { onPresentingWindowChanged?.invoke() }
|
||||
}
|
||||
}
|
||||
override fun onGameFrame() { frames.incrementAndGet() }
|
||||
override fun onGameFrame() {
|
||||
frames.incrementAndGet()
|
||||
if (!showGraph) return
|
||||
val now = System.nanoTime()
|
||||
val previous = lastFrameNs.getAndSet(now)
|
||||
if (previous == 0L) return
|
||||
val ms = ((now - previous) / 1_000_000f).coerceIn(0f, 50f)
|
||||
val slot = graphWrite.get()
|
||||
intervals[slot and (GRAPH - 1)] = ms
|
||||
graphWrite.compareAndSet(slot, slot + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private val tick = object : Runnable {
|
||||
@@ -49,28 +76,82 @@ class PerfHud(context: Context) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val dt = (now - lastTick).coerceAtLeast(1L) / 1000f
|
||||
lastTick = now
|
||||
text = line(frames.getAndSet(0) / dt)
|
||||
handler.postDelayed(this, 1000)
|
||||
lastFps = frames.getAndSet(0) / dt
|
||||
text = readout(lastFps)
|
||||
if (showGraph) publishGraph() else clearGraph()
|
||||
handler.postDelayed(this, TICK_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (enabled) start() else stop()
|
||||
readOptions()
|
||||
if (!enabled || !anyPart) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (!running) start() else {
|
||||
if (!showGraph) clearGraph() else publishGraph()
|
||||
text = readout(lastFps)
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (!enabled || running) return
|
||||
readOptions()
|
||||
if (!enabled || !anyPart || running) return
|
||||
running = true
|
||||
lastTick = SystemClock.elapsedRealtime()
|
||||
lastFrameNs.set(0L)
|
||||
frames.set(0)
|
||||
graphWrite.set(0)
|
||||
WaylandCompositor.setGameListener(listener)
|
||||
handler.post(tick)
|
||||
handler.postDelayed(tick, TICK_MS)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
handler.removeCallbacks(tick)
|
||||
WaylandCompositor.clearGameListener(listener)
|
||||
text = ""
|
||||
graph = FloatArray(0)
|
||||
}
|
||||
|
||||
private fun readOptions() {
|
||||
showFps = SessionPrefs.hudShowFps(context)
|
||||
showBattery = SessionPrefs.hudShowBattery(context)
|
||||
showGraph = SessionPrefs.hudShowGraph(context)
|
||||
}
|
||||
|
||||
private fun readout(fpsValue: Float): String {
|
||||
val parts = ArrayList<String>(2)
|
||||
if (showFps) parts.add(line(fpsValue))
|
||||
if (showBattery) batteryCelsius(readBatteryDeci())?.let { parts.add(it) }
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun readBatteryDeci(): Int {
|
||||
val sticky = runCatching {
|
||||
context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
}.getOrNull() ?: return 0
|
||||
return sticky.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0)
|
||||
}
|
||||
|
||||
private fun clearGraph() {
|
||||
lastFrameNs.set(0L)
|
||||
graphWrite.set(0)
|
||||
if (graph.isNotEmpty()) graph = FloatArray(0)
|
||||
}
|
||||
|
||||
private fun publishGraph() {
|
||||
val count = graphWrite.get()
|
||||
val shown = minOf(count, GRAPH)
|
||||
if (shown < 2) {
|
||||
graph = FloatArray(0)
|
||||
return
|
||||
}
|
||||
val out = FloatArray(shown)
|
||||
val start = count - shown
|
||||
for (i in 0 until shown) out[i] = intervals[(start + i) and (GRAPH - 1)]
|
||||
graph = out
|
||||
}
|
||||
|
||||
private fun line(base: Float): String {
|
||||
@@ -91,4 +172,12 @@ class PerfHud(context: Context) {
|
||||
}
|
||||
|
||||
private fun fps(value: Float) = String.format(Locale.US, "%.0f fps", value)
|
||||
|
||||
companion object {
|
||||
private const val TICK_MS = 1_000L
|
||||
private const val GRAPH = 128
|
||||
|
||||
internal fun batteryCelsius(deci: Int): String? =
|
||||
if (deci <= 0) null else String.format(Locale.US, "%.1f°C", deci / 10f)
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,10 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper",
|
||||
"usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper",
|
||||
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
|
||||
"usr/local/bin/droiddeck-pipewire" to "usr/local/bin/droiddeck-pipewire",
|
||||
"usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings",
|
||||
"usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game",
|
||||
"usr/local/bin/droiddeck-steamvr-flat" to "usr/local/bin/droiddeck-steamvr-flat",
|
||||
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
|
||||
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
|
||||
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
|
||||
@@ -137,6 +139,7 @@ object SessionFiles {
|
||||
val optional = (arrayOf(
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
|
||||
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
|
||||
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
|
||||
@@ -21,12 +21,14 @@ object SessionPrefs {
|
||||
const val SUSPEND_NEVER = "never"
|
||||
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
|
||||
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
|
||||
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
const val CONTROLLER_STEAM = "steamcontroller"
|
||||
const val CONTROLLER_DS4 = "dualshock4"
|
||||
const val CONTROLLER_DUALSENSE = "dualsense"
|
||||
const val CONTROLLER_NONE = "none"
|
||||
const val OSC_AUTO = "auto"
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
@@ -49,9 +51,9 @@ object SessionPrefs {
|
||||
)
|
||||
|
||||
fun orientation(context: Context): Int =
|
||||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
|
||||
.takeIf { value -> orientationOptions.any { it.first == value } }
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
|
||||
fun setOrientation(context: Context, orientation: Int) {
|
||||
require(orientationOptions.any { it.first == orientation })
|
||||
@@ -144,6 +146,13 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("storesInstallTarget", path).apply()
|
||||
}
|
||||
|
||||
/** Empty means follow the app/system language for GOG and Epic installs. */
|
||||
fun storesInstallLanguage(context: Context): String = prefs(context).getString("storesInstallLanguage", "") ?: ""
|
||||
|
||||
fun setStoresInstallLanguage(context: Context, code: String) {
|
||||
prefs(context).edit().putString("storesInstallLanguage", code).apply()
|
||||
}
|
||||
|
||||
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
|
||||
|
||||
fun setGameStoresParallel(context: Context, count: Int) {
|
||||
@@ -156,6 +165,43 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean(hudKey(context), on).apply()
|
||||
}
|
||||
|
||||
fun hudShowFps(context: Context): Boolean = prefs(context).getBoolean("hudFps", true)
|
||||
|
||||
fun setHudShowFps(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("hudFps", on).apply()
|
||||
}
|
||||
|
||||
fun hudShowBattery(context: Context): Boolean = prefs(context).getBoolean("hudBattery", true)
|
||||
|
||||
fun setHudShowBattery(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("hudBattery", on).apply()
|
||||
}
|
||||
|
||||
fun hudShowGraph(context: Context): Boolean = prefs(context).getBoolean("hudGraph", true)
|
||||
|
||||
fun setHudShowGraph(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("hudGraph", on).apply()
|
||||
}
|
||||
|
||||
fun hudColor(context: Context): String =
|
||||
prefs(context).getString("hudColor", HUD_COLOR_MINT)?.takeIf { it in HUD_COLORS } ?: HUD_COLOR_MINT
|
||||
|
||||
fun setHudColor(context: Context, color: String) {
|
||||
if (color in HUD_COLORS) prefs(context).edit().putString("hudColor", color).apply()
|
||||
}
|
||||
|
||||
fun hudOpacity(context: Context): Int =
|
||||
prefs(context).getInt("hudOpacity", HUD_OPACITY_DEFAULT).takeIf { it in HUD_OPACITY_RANGE } ?: HUD_OPACITY_DEFAULT
|
||||
|
||||
fun setHudOpacity(context: Context, percent: Int) {
|
||||
prefs(context).edit().putInt("hudOpacity", percent.coerceIn(HUD_OPACITY_RANGE)).apply()
|
||||
}
|
||||
|
||||
const val HUD_COLOR_MINT = "mint"
|
||||
val HUD_COLORS = listOf(HUD_COLOR_MINT, "white", "sky", "amber", "violet")
|
||||
val HUD_OPACITY_RANGE = 30..100
|
||||
const val HUD_OPACITY_DEFAULT = 70
|
||||
|
||||
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
|
||||
|
||||
private fun mangoappSession(context: Context) =
|
||||
@@ -249,6 +295,16 @@ object SessionPrefs {
|
||||
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* After a game launched from the front end (or another launcher's link) closes, the client
|
||||
* exits cleanly and the session returns there, instead of staying in Big Picture.
|
||||
*/
|
||||
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
|
||||
|
||||
fun setReturnAfterGame(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("returnAfterGame", on).apply()
|
||||
}
|
||||
|
||||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||||
|
||||
fun setStretch16x9(context: Context, on: Boolean) {
|
||||
@@ -420,6 +476,8 @@ object SessionPrefs {
|
||||
fun steamController(context: Context): String =
|
||||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||||
|
||||
fun builtinPad(context: Context): Boolean = steamController(context) != CONTROLLER_NONE
|
||||
fun padType(id: String): Int = when (id) {
|
||||
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
|
||||
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
|
||||
@@ -953,6 +1011,12 @@ object SessionPrefs {
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun deckyZipWarningSeen(context: Context): Boolean = prefs(context).getBoolean(DECKY_ZIP_WARNING_SEEN, false)
|
||||
|
||||
fun setDeckyZipWarningSeen(context: Context) {
|
||||
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
|
||||
}
|
||||
|
||||
fun steamDownloadsInBackground(context: Context): Boolean {
|
||||
val prefs = prefs(context)
|
||||
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
|
||||
|
||||
@@ -71,6 +71,7 @@ class SessionService : Service() {
|
||||
private val steamDownloadMonitor = SteamDownloadMonitor()
|
||||
private var downloadMonitorTask: Runnable? = null
|
||||
private var downloadActive = false
|
||||
@Volatile private var steamLibraryMounts = emptyMap<String, File>()
|
||||
private var suspendDownloadNow = false
|
||||
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
|
||||
private var steamDownloadsInBackground = false
|
||||
@@ -92,6 +93,8 @@ class SessionService : Service() {
|
||||
private var suspendAttemptFailed = false
|
||||
private var pendingSteamGameId: String? = null
|
||||
private var pendingSteamGameGeneration = -1
|
||||
// Bumped to cancel a running game-exit watch (a new launch, or the session ending).
|
||||
@Volatile private var gameExitWatchToken = 0
|
||||
private val flushSteamGame = object : Runnable {
|
||||
override fun run() = flushQueuedSteamGame()
|
||||
}
|
||||
@@ -309,6 +312,8 @@ class SessionService : Service() {
|
||||
val gen = ++sessionGen
|
||||
cancelQueuedSteamGame(clearMailbox = false)
|
||||
clearSteamGameMailbox()
|
||||
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl
|
||||
?.takeIf { it.startsWith(GAME_URL_PREFIX) }?.let { watchGameExit(it.removePrefix(GAME_URL_PREFIX), gen) }
|
||||
acquireLocks()
|
||||
Thread({
|
||||
OrphanReaper.reap("session starting")
|
||||
@@ -394,6 +399,12 @@ class SessionService : Service() {
|
||||
val fastPathAt = guest.size
|
||||
|
||||
val pulse = startAudio(guest, sessionDir)
|
||||
if (steamHere) {
|
||||
val encoder = VideoEncodeBridge(File(filesDir, "venc/venc.sock"))
|
||||
encoder.attach(this)
|
||||
components.add(encoder)
|
||||
guest.add("BL_VENC_SOCKET=" + encoder.socket.absolutePath)
|
||||
}
|
||||
|
||||
guest.add("BL_WIDTH=" + size.first)
|
||||
guest.add("BL_HEIGHT=" + size.second)
|
||||
@@ -702,17 +713,21 @@ class SessionService : Service() {
|
||||
|
||||
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File, sessionDir: File) {
|
||||
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
|
||||
val builtinPad = SessionPrefs.builtinPad(this)
|
||||
val hiddenPad = SessionState.mode == MODE_STEAM && !builtinPad
|
||||
fakeInputDir.listFiles()?.forEach { file ->
|
||||
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
|
||||
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
|
||||
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD) ||
|
||||
(hiddenPad && node == 0)) file.delete()
|
||||
}
|
||||
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
|
||||
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
|
||||
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
|
||||
val presented = if (hiddenPad) rings?.split(';')?.filterNot { it.startsWith("0=") }?.joinToString(";") else rings
|
||||
if (!presented.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$presented")
|
||||
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
|
||||
guest.add("FAKE_EVDEV_VIBRATION=1")
|
||||
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
val wantsDeck = uinput && builtinPad && SessionState.mode == MODE_STEAM &&
|
||||
SessionState.steamUi != "desktop" &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
|
||||
@@ -802,6 +817,7 @@ class SessionService : Service() {
|
||||
File(home, "Storage").mkdirs()
|
||||
binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage")
|
||||
val library = GameStorage.effective(this)
|
||||
steamLibraryMounts = emptyMap()
|
||||
var storageDiagnosticLibrary: File? = null
|
||||
if (library != null) {
|
||||
val problem = GameStorage.prepare(this, library.path)
|
||||
@@ -813,6 +829,7 @@ class SessionService : Service() {
|
||||
val content = SecondaryLibrary.contentPath(configured)
|
||||
binds.addAll(SecondaryLibrary.binds(filesDir, configured, content = content))
|
||||
storageDiagnosticLibrary = content
|
||||
steamLibraryMounts = SecondaryLibrary.CARD_GUESTS.associateWith { content }
|
||||
val route = if (content === configured) "configured" else "verified-native"
|
||||
Log.i(TAG, "game storage: ${content.path} ($route) -> /mnt/droiddeck-sd (\"${library.label}\"); prefixes and native tools private")
|
||||
} catch (e: Exception) {
|
||||
@@ -1071,9 +1088,71 @@ class SessionService : Service() {
|
||||
pendingSteamGameId = null
|
||||
pendingSteamGameGeneration = -1
|
||||
SessionEvents.record("steam.game_launch_requested", mapOf("gameId" to gameId, "generation" to gen))
|
||||
watchGameExit(gameId, gen)
|
||||
Log.i(TAG, "queued Steam game $gameId for the running client")
|
||||
}
|
||||
|
||||
/**
|
||||
* A game launched from the front end (the Games tab, or another launcher's link): once it has
|
||||
* started and then been gone for [GAME_EXIT_GRACE_MS], ask the client to exit through the
|
||||
* session script's steam-stop request. The client quits cleanly, finishing its cloud sync, so
|
||||
* the session ends on its own and the activity returns to where the launch came from instead
|
||||
* of leaving the player in Big Picture. The grace covers launchers that restart the game.
|
||||
*/
|
||||
private fun watchGameExit(gameId: String, gen: Int) {
|
||||
if (!SessionPrefs.returnAfterGame(this)) return
|
||||
val token = ++gameExitWatchToken
|
||||
Thread({
|
||||
val startBy = android.os.SystemClock.elapsedRealtime() + GAME_START_TIMEOUT_MS
|
||||
var seen = false
|
||||
var goneSince = 0L
|
||||
while (token == gameExitWatchToken && gen == sessionGen && SessionState.running && !SessionState.stopRequested) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (steamGameRunning()) {
|
||||
if (!seen) Log.i(TAG, "game $gameId started; returning to the launcher when it closes")
|
||||
seen = true
|
||||
goneSince = 0L
|
||||
} else if (!seen) {
|
||||
// Never started (an install, an update or a failed launch): leave the session be.
|
||||
if (now > startBy) {
|
||||
Log.i(TAG, "game $gameId did not start; not watching for its exit")
|
||||
return@Thread
|
||||
}
|
||||
} else if (goneSince == 0L) {
|
||||
goneSince = now
|
||||
} else if (now - goneSince >= GAME_EXIT_GRACE_MS) {
|
||||
mainHandler.post { returnAfterGame(gameId, gen, token) }
|
||||
return@Thread
|
||||
}
|
||||
try { Thread.sleep(GAME_EXIT_POLL_MS) } catch (e: InterruptedException) { return@Thread }
|
||||
}
|
||||
}, "game-exit-watch").start()
|
||||
}
|
||||
|
||||
private fun returnAfterGame(gameId: String, gen: Int, token: Int) {
|
||||
if (token != gameExitWatchToken || gen != sessionGen || !SessionState.running ||
|
||||
SessionState.stopRequested || SessionState.mode != MODE_STEAM || !SessionPrefs.returnAfterGame(this)) return
|
||||
val written = runCatching { File(LinuxRuntime.sessionRoot(this), "steam-stop").writeText("") }
|
||||
.onFailure { Log.w(TAG, "could not ask the client to exit after game $gameId", it) }.isSuccess
|
||||
if (!written) return
|
||||
SessionEvents.record("steam.return_after_game", mapOf("gameId" to gameId))
|
||||
Log.i(TAG, "game $gameId closed: asking the client to exit and returning to the launcher")
|
||||
}
|
||||
|
||||
/** A Steam game is running: the client runs each one under its reaper, and Proton's script
|
||||
* waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */
|
||||
private fun steamGameRunning(): Boolean {
|
||||
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 (text.contains("SteamLaunch AppId=") || text.contains("waitforexitandrun")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun resumeSession(panelVisible: Boolean = true) {
|
||||
if (panelVisible) activityVisible = true
|
||||
screenOn = readScreenOn()
|
||||
@@ -1271,13 +1350,13 @@ class SessionService : Service() {
|
||||
|
||||
private fun updateSuspendPolicy() {
|
||||
if (!SessionState.running) return
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && !hasVisibleSurface) {
|
||||
manualPauseRequested = true
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !hasVisibleSurface
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) {
|
||||
manualPauseRequested = true
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
|
||||
@@ -1473,6 +1552,7 @@ class SessionService : Service() {
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
cancelQueuedSteamGame()
|
||||
gameExitWatchToken++
|
||||
completeNativeSleep()
|
||||
val (ended, endedArch) = synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
@@ -1547,7 +1627,6 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (rootIntent?.component?.className == com.droiddeck.launcher.ui.SecondScreenActivity::class.java.name) return
|
||||
if (pipTask || xrActivityVisible) {
|
||||
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
|
||||
activityVisible = false
|
||||
@@ -1712,6 +1791,7 @@ class SessionService : Service() {
|
||||
LinuxRuntime.rootDir(this@SessionService),
|
||||
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
|
||||
client = SessionState.steamClientArch,
|
||||
mounts = steamLibraryMounts,
|
||||
)
|
||||
if (wasActive != downloadActive) refreshNotification()
|
||||
updateSuspendPolicy()
|
||||
@@ -1730,7 +1810,9 @@ class SessionService : Service() {
|
||||
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
|
||||
}
|
||||
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this)
|
||||
else if (SessionState.mode == MODE_STEAM && !SessionPrefs.builtinPad(this)) "one per connected controller, no built-in pad"
|
||||
else "an Xbox 360 controller")
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -1775,6 +1857,12 @@ class SessionService : Service() {
|
||||
private const val GAME_LAUNCH_RETRY_MS = 250L
|
||||
private const val DOWNLOAD_POLL_MS = 2_000L
|
||||
private const val STEAM_GAME_REQUEST = "steam-game"
|
||||
private const val GAME_URL_PREFIX = "steam://rungameid/"
|
||||
/** How long a front-end launch may take to start (downloads, first-run installs) before the exit watch gives up. */
|
||||
private const val GAME_START_TIMEOUT_MS = 20 * 60_000L
|
||||
/** How long the game must stay gone before the client is asked to exit: launchers restart games. */
|
||||
private const val GAME_EXIT_GRACE_MS = 10_000L
|
||||
private const val GAME_EXIT_POLL_MS = 2_000L
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
|
||||
|
||||
@@ -13,7 +13,8 @@ object SettingsReset {
|
||||
internal val SESSION_KEYS = setOf(
|
||||
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
|
||||
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
|
||||
"hud", "hudDeck", "backActionsInverted", "touch", "osc", "shape", "steamController",
|
||||
"storesInstallLanguage",
|
||||
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "touch", "osc", "shape", "steamController",
|
||||
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9", "picturePosition",
|
||||
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
|
||||
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
|
||||
|
||||
@@ -18,8 +18,9 @@ internal class SteamDownloadMonitor {
|
||||
private var lastReceivedBytes = -1L
|
||||
private var lastReceivedAt = 0L
|
||||
|
||||
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null): Boolean {
|
||||
val current = manifests(runtimeRoot).associate { it.path to read(it) }
|
||||
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null,
|
||||
mounts: Map<String, File> = emptyMap()): Boolean {
|
||||
val current = manifests(runtimeRoot, mounts).associate { it.path to read(it) }
|
||||
val progressed = current.any { (path, state) ->
|
||||
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
|
||||
}
|
||||
@@ -98,7 +99,7 @@ internal class SteamDownloadMonitor {
|
||||
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
|
||||
}
|
||||
|
||||
private fun manifests(runtimeRoot: File): List<File> {
|
||||
private fun manifests(runtimeRoot: File, mounts: Map<String, File>): List<File> {
|
||||
val steamRoots = linkedSetOf<File>()
|
||||
for (home in STEAM_HOMES) {
|
||||
val primary = File(runtimeRoot, home)
|
||||
@@ -106,7 +107,7 @@ internal class SteamDownloadMonitor {
|
||||
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
|
||||
runCatching {
|
||||
PATH.findAll(libraryFolders.readText()).forEach { match ->
|
||||
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
|
||||
steamRoots += hostPath(runtimeRoot, match.groupValues[1], mounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +118,15 @@ internal class SteamDownloadMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hostPath(runtimeRoot: File, guest: String, mounts: Map<String, File>): File {
|
||||
val path = guest.trimEnd('/')
|
||||
for ((mount, host) in mounts) {
|
||||
if (path == mount) return host
|
||||
if (path.startsWith("$mount/")) return File(host, path.removePrefix("$mount/"))
|
||||
}
|
||||
return File(runtimeRoot, path.removePrefix("/"))
|
||||
}
|
||||
|
||||
private fun read(file: File): ManifestState {
|
||||
val text = runCatching { file.readText() }.getOrDefault("")
|
||||
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import java.io.File
|
||||
|
||||
/** Steam's Remote Play V4L2 encoder in the guest talks to this MediaCodec encoder (venc.c, venc_server.cpp). */
|
||||
class VideoEncodeBridge(val socket: File) : SessionPart() {
|
||||
override fun start() {
|
||||
socket.parentFile?.mkdirs()
|
||||
val ok = runCatching { System.loadLibrary("ddvenc"); nativeStart(socket.absolutePath) }
|
||||
.onFailure { Log.w(TAG, "hardware video encoding unavailable", it) }
|
||||
.getOrDefault(false)
|
||||
Log.i(TAG, if (ok) "Remote Play hardware encoding on at $socket" else "Remote Play hardware encoding off")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
runCatching { nativeStop() }
|
||||
socket.delete()
|
||||
}
|
||||
|
||||
private external fun nativeStart(path: String): Boolean
|
||||
private external fun nativeStop()
|
||||
|
||||
private companion object {
|
||||
const val TAG = "VideoEncodeBridge"
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,11 @@ data class CatalogItem(
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
val mature: Boolean = false,
|
||||
val subscription: Boolean = false,
|
||||
val includedWith: String = "",
|
||||
) {
|
||||
val accessible: Boolean get() = owned || subscription
|
||||
/** Licensed now, or a supported package the account's subscription can install. */
|
||||
val installable: Boolean get() = accessible || includedWith.isNotBlank()
|
||||
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
|
||||
val key: String get() = "${store.id}:$id"
|
||||
}
|
||||
@@ -86,6 +89,18 @@ internal fun retainCatalogOrder(items: List<CatalogItem>, previous: List<Catalog
|
||||
return previous.map { byKey.getValue(it.key) }
|
||||
}
|
||||
|
||||
/** A detail page opens for search results too; the library wins when it already knows the product. */
|
||||
fun detailCatalogItem(
|
||||
store: Store,
|
||||
id: String,
|
||||
library: List<CatalogItem>,
|
||||
found: List<CatalogItem>,
|
||||
shelves: StoreShelves?,
|
||||
): CatalogItem? =
|
||||
library.firstOrNull { it.store == store && it.id == id }
|
||||
?: found.firstOrNull { it.store == store && it.id == id }
|
||||
?: shelves?.all?.firstOrNull { it.store == store && it.id == id }
|
||||
|
||||
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
|
||||
if (old.isNullOrEmpty()) return new
|
||||
val before = old.associateBy { it.key }
|
||||
@@ -93,6 +108,19 @@ fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogIte
|
||||
return if (merged.size == old.size && merged.indices.all { merged[it] === old[it] }) old else merged
|
||||
}
|
||||
|
||||
/**
|
||||
* [patched] over [old] by key, the way a store that fills its cards in one at a time needs it: a card
|
||||
* already on screen keeps its very object and its place, one the grid does not have yet is added where
|
||||
* its title belongs. Unlike [mergeItems] the rest of [old] is never dropped.
|
||||
*/
|
||||
fun patchItems(old: List<CatalogItem>?, patched: List<CatalogItem>): List<CatalogItem> {
|
||||
if (old.isNullOrEmpty()) return patched
|
||||
val byKey = patched.associateBy { it.key }
|
||||
val kept = old.map { byKey[it.key] ?: it }
|
||||
val added = patched.filter { n -> old.none { it.key == n.key } }
|
||||
return if (added.isEmpty()) kept else (kept + added).sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) {
|
||||
val key: String get() = "${sidecar.store.id}:${sidecar.id}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.AppLanguage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONArray
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Language for a third-party store install ([Store.GOG], [Store.EPIC]): the app/system locale by
|
||||
* default, or the code the user picked in the install dialog / store settings.
|
||||
*
|
||||
* GOG depots and Epic install tags use overlapping but not identical codes (`en` vs `en-US`,
|
||||
* `nb` vs `no`). Matching is on the language part plus a few aliases, so a Polish phone selects
|
||||
* `pl-PL` depots without the user having to know the store's spelling.
|
||||
*/
|
||||
object StoreLanguage {
|
||||
const val EXTRA = "installLanguage"
|
||||
|
||||
/** Codes the settings list and Epic's tag table share; GOG often uses the same with a region. */
|
||||
val KNOWN: List<String> = listOf(
|
||||
"en", "pl", "de", "fr", "es", "es-419", "pt", "pt-BR", "it", "ru", "uk", "cs", "hu",
|
||||
"nl", "sv", "da", "fi", "nb", "ro", "bg", "el", "tr", "ar", "th", "vi", "ja", "ko",
|
||||
"zh-Hans", "zh-Hant",
|
||||
)
|
||||
|
||||
private val ALIASES = mapOf(
|
||||
"english" to "en", "polish" to "pl", "german" to "de", "french" to "fr", "spanish" to "es",
|
||||
"italian" to "it", "russian" to "ru", "ukrainian" to "uk", "czech" to "cs", "hungarian" to "hu",
|
||||
"dutch" to "nl", "swedish" to "sv", "danish" to "da", "finnish" to "fi", "norwegian" to "nb",
|
||||
"no" to "nb", "romanian" to "ro", "bulgarian" to "bg", "greek" to "el", "turkish" to "tr",
|
||||
"arabic" to "ar", "thai" to "th", "vietnamese" to "vi", "japanese" to "ja", "korean" to "ko",
|
||||
"schinese" to "zh-hans", "tchinese" to "zh-hant", "chinese" to "zh-hans",
|
||||
"brazilian" to "pt-br", "portuguese" to "pt",
|
||||
)
|
||||
|
||||
fun selectable(store: Store): Boolean = store == Store.GOG || store == Store.EPIC
|
||||
|
||||
/** Tag actually sent to the store: settings override, else the app/system locale. */
|
||||
@JvmStatic
|
||||
fun preferredCode(context: Context): String {
|
||||
val chosen = SessionPrefs.storesInstallLanguage(context)
|
||||
if (chosen.isNotEmpty()) return chosen
|
||||
return tagFor(AppLanguage.effective(context))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun preferredCode(): String = tagFor(Locale.getDefault())
|
||||
|
||||
fun tagFor(locale: Locale): String {
|
||||
val language = locale.language.ifEmpty { "en" }
|
||||
val country = locale.country
|
||||
val script = locale.script
|
||||
return when {
|
||||
language == "zh" && (script == "Hant" || country == "TW" || country == "HK" || country == "MO") -> "zh-Hant"
|
||||
language == "zh" -> "zh-Hans"
|
||||
language == "pt" && (country.isEmpty() || country == "BR") -> "pt-BR"
|
||||
language == "es" && country.isNotEmpty() && country != "ES" -> "es-419"
|
||||
country.isNotEmpty() -> "$language-$country"
|
||||
else -> language
|
||||
}
|
||||
}
|
||||
|
||||
fun localeFor(code: String?, context: Context): Locale {
|
||||
val tag = code?.takeIf { it.isNotBlank() } ?: preferredCode(context)
|
||||
return Locale.forLanguageTag(tag.replace('_', '-'))
|
||||
}
|
||||
|
||||
fun fallbackList(store: Store): List<String> = if (selectable(store)) KNOWN else emptyList()
|
||||
|
||||
/**
|
||||
* One code from [available]. An exact normalized code wins (`pl-PL` over `pl`). A base-language
|
||||
* match is used only when that exact code is not offered, then English, then the first code.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun pick(available: List<String>, preferred: String): String {
|
||||
if (available.isEmpty()) return preferred
|
||||
val want = primary(preferred) ?: return available.first()
|
||||
return available.firstOrNull { primary(it) == want }
|
||||
?: available.firstOrNull { depotAccepted(arrayOf(it), preferred) }
|
||||
?: available.firstOrNull { depotAccepted(arrayOf(it), "en") }
|
||||
?: available.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a GOG depot whose `languages` array is [listed] overlaps [chosen], including a
|
||||
* base-language match (`pl` accepts `pl-PL`). Shared depots (`*`, empty, missing) always match.
|
||||
* Install selection uses [depotMatchesResolved] after [pick] has chosen a single code.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun depotAccepted(listed: Array<String>?, chosen: String): Boolean {
|
||||
if (listed.isNullOrEmpty()) return true
|
||||
val want = tokens(chosen)
|
||||
return listed.any { have ->
|
||||
val parts = tokens(have)
|
||||
parts.contains("*") || parts.any { it in want }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [listed] is shared, or contains the one code [resolved] already selected.
|
||||
* `pl-PL` does not also keep a `pl` depot.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun depotMatchesResolved(listed: Array<String>?, resolved: String): Boolean {
|
||||
if (listed.isNullOrEmpty()) return true
|
||||
if (listed.any { it.trim() == "*" }) return true
|
||||
val want = primary(resolved) ?: return false
|
||||
return listed.any { primary(it) == want }
|
||||
}
|
||||
|
||||
/** Normalized code without collapsing to the base language. `pl-PL` stays `pl-pl`. */
|
||||
internal fun primary(code: String): String? {
|
||||
val raw = code.trim().lowercase().replace('_', '-')
|
||||
if (raw.isEmpty()) return null
|
||||
if (raw == "*") return "*"
|
||||
return ALIASES[raw] ?: raw
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun codesFromDepots(depots: JSONArray?): List<String> {
|
||||
if (depots == null) return emptyList()
|
||||
val seen = LinkedHashSet<String>()
|
||||
for (i in 0 until depots.length()) {
|
||||
val langs = depots.optJSONObject(i)?.optJSONArray("languages") ?: continue
|
||||
for (j in 0 until langs.length()) {
|
||||
val raw = langs.optString(j, "").trim()
|
||||
if (raw.isEmpty() || raw == "*") continue
|
||||
val key = raw.lowercase()
|
||||
if (seen.none { it.lowercase() == key }) seen.add(raw)
|
||||
}
|
||||
}
|
||||
return seen.toList()
|
||||
}
|
||||
|
||||
fun displayName(code: String): String {
|
||||
if (code.isEmpty()) return ""
|
||||
val locale = Locale.forLanguageTag(code.replace('_', '-'))
|
||||
AppLanguage.supportedTag(locale)?.let { return AppLanguage.nativeName(it) }
|
||||
val name = locale.getDisplayName(locale)
|
||||
return name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||
}
|
||||
|
||||
internal fun tokens(code: String): Set<String> {
|
||||
val raw = code.trim().lowercase().replace('_', '-')
|
||||
if (raw.isEmpty()) return emptySet()
|
||||
if (raw == "*") return setOf("*")
|
||||
val mapped = ALIASES[raw] ?: raw
|
||||
val lang = mapped.substringBefore('-').let { ALIASES[it] ?: it }
|
||||
val out = mutableSetOf(mapped, lang)
|
||||
if (mapped.startsWith("zh-hant") || mapped == "zh-tw" || mapped == "zh-hk") {
|
||||
out.add("zh-hant"); out.add("zh")
|
||||
}
|
||||
if (mapped.startsWith("zh-hans") || mapped == "zh-cn") {
|
||||
out.add("zh-hans"); out.add("zh")
|
||||
}
|
||||
if (mapped == "pt-br" || mapped == "br") out.add("pt-br")
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ internal object StoreNet {
|
||||
headers.forEach { (k, v) -> setRequestProperty(k, v) }
|
||||
}
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) { Log.w(TAG, "GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
|
||||
if (code !in 200..299) { StoresState.logLine("GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
|
||||
null
|
||||
|
||||
@@ -99,7 +99,10 @@ object StoreSizes {
|
||||
val json = EpicApiClient.getManifestApiJson(token, ns, catalogItemId, item.id) ?: return 0L
|
||||
val bytes = EpicDownloadManager.downloadManifest(json, EpicDownloadManager.parseCdnUrls(json)) ?: return 0L
|
||||
val manifest = EpicDownloadManager.parseManifest(bytes) ?: return 0L
|
||||
EpicDownloadManager.resolveInstallFiles(manifest, EpicInstallTags.tagsForDevice()).sumOf { it.fileSize() }
|
||||
EpicDownloadManager.resolveInstallFiles(
|
||||
manifest,
|
||||
EpicInstallTags.tagsFor(StoreLanguage.localeFor(null, context)),
|
||||
).sumOf { it.fileSize() }
|
||||
}
|
||||
Store.AMAZON -> {
|
||||
val token = AmazonCredentialStore.getValidAccessToken(context) ?: return 0L
|
||||
|
||||
@@ -128,6 +128,10 @@ object StoresState {
|
||||
|
||||
fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id }
|
||||
|
||||
fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem(
|
||||
store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store],
|
||||
)
|
||||
|
||||
fun open(context: Context, store: Store, force: Boolean = false) {
|
||||
val backend = backends[store] ?: return
|
||||
val app = context.applicationContext
|
||||
@@ -198,32 +202,35 @@ object StoresState {
|
||||
askForNotifications(context)
|
||||
if (isUnfinished(item)) { install(context, item); return }
|
||||
val targets = StoreInstallRoot.targets(context)
|
||||
if (targets.size > 1) pendingInstall = item else install(context, item, targets.first().root)
|
||||
if (targets.size > 1 || StoreLanguage.selectable(item.store)) pendingInstall = item
|
||||
else install(context, item, targets.first().root)
|
||||
}
|
||||
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File? = null, language: String? = null) {
|
||||
val app = context.applicationContext
|
||||
val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, root ?: StoreInstallRoot.installRoot(app))
|
||||
val target = folder.parentFile!!.parentFile!!
|
||||
val check = StoreInstallRoot.checkStorage(app, item, target)
|
||||
val code = language?.takeIf { it.isNotBlank() } ?: StoreLanguage.preferredCode(app)
|
||||
val tagged = if (StoreLanguage.selectable(item.store)) item.copy(extra = item.extra + (StoreLanguage.EXTRA to code)) else item
|
||||
val check = StoreInstallRoot.checkStorage(app, tagged, target)
|
||||
if (!check.enough) {
|
||||
val errorMsg = check.errorMessage ?: app.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(check.requiredBytes), formatBytes(check.availableBytes))
|
||||
val entry = com.droiddeck.launcher.stores.download.DownloadEntry(
|
||||
item.store,
|
||||
item.id,
|
||||
item.title,
|
||||
cover = item.imageUrl,
|
||||
bytesTotal = item.sizeBytes,
|
||||
tagged.store,
|
||||
tagged.id,
|
||||
tagged.title,
|
||||
cover = tagged.imageUrl,
|
||||
bytesTotal = tagged.sizeBytes,
|
||||
location = StoreInstallRoot.labelFor(app, folder)
|
||||
)
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, tagged, target)
|
||||
com.droiddeck.launcher.stores.download.DownloadQueue.recordFailed(app, entry, errorMsg) {
|
||||
install(app, item, target)
|
||||
install(app, tagged, target, code)
|
||||
}
|
||||
return
|
||||
}
|
||||
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")
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, tagged, target)
|
||||
backends[tagged.store]?.install(app, tagged, target) ?: logLine("${tagged.store.label}: this build cannot install yet")
|
||||
}
|
||||
|
||||
val uninstalling = mutableStateMapOf<String, Float>()
|
||||
|
||||
@@ -8,17 +8,20 @@ object EpicInstallTags {
|
||||
|
||||
@Volatile private var last: Answer? = null
|
||||
|
||||
private fun current(): Answer? {
|
||||
val locale = Locale.getDefault()
|
||||
private fun answer(locale: Locale): Answer? {
|
||||
last?.takeIf { it.locale == locale }?.let { return it }
|
||||
val answer = EpicNative.call("locale", JSONObject().put("language", locale.language).put("region", locale.country)) ?: return null
|
||||
val list = answer.optJSONArray("tags")
|
||||
val call = EpicNative.call("locale", JSONObject().put("language", locale.language).put("region", locale.country)) ?: return null
|
||||
val list = call.optJSONArray("tags")
|
||||
val tags = List(list?.length() ?: 0) { list!!.optString(it) }.filter { it.isNotEmpty() }
|
||||
return Answer(locale, tags, answer.optString("launchLocale", "en").ifEmpty { "en" }).also { last = it }
|
||||
return Answer(locale, tags, call.optString("launchLocale", "en").ifEmpty { "en" }).also { last = it }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun tagsForDevice(): List<String> = current()?.tags ?: emptyList()
|
||||
fun tagsFor(locale: Locale): List<String> = answer(locale)?.tags ?: emptyList()
|
||||
|
||||
fun launchLocale(): String = current()?.launchLocale ?: "en"
|
||||
fun launchLocale(locale: Locale): String = answer(locale)?.launchLocale ?: "en"
|
||||
|
||||
@JvmStatic
|
||||
fun tagsForDevice(): List<String> = tagsFor(Locale.getDefault())
|
||||
|
||||
fun launchLocale(): String = launchLocale(Locale.getDefault())
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.droiddeck.launcher.stores.StoreExe
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreLanguage
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
||||
@@ -29,6 +30,8 @@ internal class EpicInstaller(
|
||||
|
||||
private fun scratch(): File = StoreInstallRoot.scratchDir(app, Store.EPIC, item.id)
|
||||
|
||||
private fun installLocale() = StoreLanguage.localeFor(item.extra[StoreLanguage.EXTRA], app)
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
|
||||
val token = EpicCredentialStore.getValidAccessToken(app) ?: throw IllegalStateException("Not signed in to Epic Games")
|
||||
@@ -47,7 +50,7 @@ internal class EpicInstaller(
|
||||
.put("gameDir", folder.absolutePath)
|
||||
.put("scratchDir", if (StoreInstallRoot.isRemovable(app, folder)) scratch().absolutePath else "")
|
||||
.put("storageLabel", StoreInstallRoot.labelFor(app, folder))
|
||||
.put("tags", JSONArray(EpicInstallTags.tagsForDevice()))
|
||||
.put("tags", JSONArray(EpicInstallTags.tagsFor(installLocale())))
|
||||
.put("maxWorkers", tier.networkWindow.coerceIn(1, 128))
|
||||
.put("processWorkers", tier.processWorkers)
|
||||
.put("caBundle", "")
|
||||
@@ -111,7 +114,7 @@ internal class EpicInstaller(
|
||||
val argsAnswer = EpicNative.call(
|
||||
"launch.args",
|
||||
JSONObject().put("displayName", creds?.displayName.orEmpty()).put("accountId", creds?.accountId.orEmpty())
|
||||
.put("namespace", namespace).put("locale", EpicInstallTags.launchLocale()).put("appName", item.id)
|
||||
.put("namespace", namespace).put("locale", EpicInstallTags.launchLocale(installLocale())).put("appName", item.id)
|
||||
.put("deploymentId", deployment).put("additional", additional),
|
||||
)
|
||||
val argList = argsAnswer?.optJSONArray("args")
|
||||
@@ -122,6 +125,7 @@ internal class EpicInstaller(
|
||||
extra["namespace"] = namespace
|
||||
extra["catalogItemId"] = catalogItemId
|
||||
if (deployment.isNotEmpty()) extra["deploymentId"] = deployment
|
||||
extra[StoreLanguage.EXTRA] = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app)
|
||||
extra[WinCompSources.EPIC_PREREQ_PATH] = result.optString("prereqPath")
|
||||
extra[WinCompSources.EPIC_PREREQ_NAME] = result.optString("prereqName")
|
||||
if (result.optBoolean("prereqRead", false)) extra[WinCompSources.LIST_READ] = "1"
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.droiddeck.launcher.stores.StoreBackend
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreLanguage
|
||||
import com.droiddeck.launcher.stores.StoreLoginActivity
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
@@ -37,7 +38,16 @@ object GogBackend : StoreBackend {
|
||||
if (cached.isNotEmpty()) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached)
|
||||
if (!quiet) StoresState.status[store] = app.getString(com.droiddeck.launcher.R.string.stores_sync_library)
|
||||
}
|
||||
val result = GogLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }
|
||||
val result = GogLibrary.sync(app, force, onStatus = { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }, onList = { list ->
|
||||
// Mapped here, off the main thread: the listing paints the tab before any product is asked about.
|
||||
val items = list.map { GogLibrary.toCatalogItem(app, it) }
|
||||
// An empty listing is not news: posting it would drop the cards already on screen.
|
||||
if (items.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], items) }
|
||||
}, onItems = { changed ->
|
||||
// Only the products that came back are touched: a card already on screen keeps its object, and a product the grid has not seen yet is added where its title belongs.
|
||||
val items = changed.map { GogLibrary.toCatalogItem(app, it) }
|
||||
StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.patchItems(StoresState.library[store], items) }
|
||||
})
|
||||
StoresState.post {
|
||||
StoresState.status.remove(store)
|
||||
when (result) {
|
||||
@@ -82,6 +92,7 @@ object GogBackend : StoreBackend {
|
||||
handle.stage(DownloadStage.MANIFEST)
|
||||
StoreInstalls.begin(folder, Store.GOG, game.gameId, game.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl)
|
||||
var downloading = false
|
||||
val language = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app)
|
||||
val result = GogDownloadManager.install(app, game, folder, object : GogDownloadManager.Callback {
|
||||
override fun onProgress(message: String, pct: Int) {
|
||||
when {
|
||||
@@ -99,15 +110,17 @@ object GogBackend : StoreBackend {
|
||||
handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL })
|
||||
handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install")
|
||||
}
|
||||
}, cancelled) ?: return null
|
||||
}, cancelled, language) ?: return null
|
||||
if (cancelled.get()) return null
|
||||
handle.stage(DownloadStage.INSTALL, app.getString(com.droiddeck.launcher.R.string.stores_registering))
|
||||
val installedLanguage = result.language?.takeIf { it.isNotEmpty() } ?: language
|
||||
val sidecar = StoreGameSidecar(
|
||||
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
|
||||
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
|
||||
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
|
||||
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
|
||||
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
|
||||
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap())
|
||||
+ mapOf(StoreLanguage.EXTRA to installedLanguage),
|
||||
)
|
||||
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
|
||||
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.stores.gog;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.stores.StoreLanguage;
|
||||
import com.droiddeck.launcher.stores.StoreLog;
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier;
|
||||
|
||||
@@ -57,6 +58,8 @@ public final class GogDownloadManager {
|
||||
public final long bytes;
|
||||
public List<String> dependencies = new ArrayList<>();
|
||||
public boolean dependenciesRead = false;
|
||||
/** Language actually written, after the manifest's depot list resolved the request. Null for builds that have no language depots. */
|
||||
public String language;
|
||||
Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
|
||||
}
|
||||
|
||||
@@ -67,17 +70,23 @@ public final class GogDownloadManager {
|
||||
private GogDownloadManager() {}
|
||||
|
||||
public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled) throws InstallException {
|
||||
return install(ctx, game, installPath, cb, cancelled, null);
|
||||
}
|
||||
|
||||
public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled, String language) throws InstallException {
|
||||
cb.onProgress("Checking sign-in…", 0);
|
||||
String token = GogAuth.validToken(ctx);
|
||||
if (token == null) throw new InstallException("Not signed in to GOG");
|
||||
if (cancelled.get()) return null;
|
||||
String lang = (language == null || language.isEmpty()) ? StoreLanguage.preferredCode(ctx) : language;
|
||||
cb.onLog("gog: installing language " + lang);
|
||||
cb.onProgress("Fetching builds…", 2);
|
||||
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + game.gameId + "/os/windows/builds?generation=2";
|
||||
String buildsJson = httpGet(buildsUrl, null);
|
||||
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
|
||||
if (buildsJson != null) {
|
||||
Gen2Outcome out = runGen2(ctx, game, token, buildsJson, installPath, cb, cancelled);
|
||||
Gen2Outcome out = runGen2(ctx, game, token, buildsJson, installPath, cb, cancelled, lang);
|
||||
if (out.result != null || cancelled.get()) return out.result;
|
||||
if (out.hardStop) throw new InstallException(out.error);
|
||||
cb.onLog("gen2 unavailable: " + out.error);
|
||||
@@ -100,7 +109,7 @@ public final class GogDownloadManager {
|
||||
Result result; String error; boolean hardStop;
|
||||
}
|
||||
|
||||
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled) {
|
||||
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) {
|
||||
Gen2Outcome out = new Gen2Outcome();
|
||||
try {
|
||||
cb.onProgress("Fetching manifest…", 5);
|
||||
@@ -127,12 +136,21 @@ public final class GogDownloadManager {
|
||||
cb.onProgress("Reading depot manifests…", 10);
|
||||
List<DepotFile> files = new ArrayList<>();
|
||||
List<String> depotJsons = new ArrayList<>();
|
||||
JSONArray owned = new JSONArray();
|
||||
List<JSONObject> ownedDepots = new ArrayList<>();
|
||||
for (int i = 0; i < depots.length(); i++) {
|
||||
if (cancelled.get()) return out;
|
||||
JSONObject depot = depots.getJSONObject(i);
|
||||
String depotPid = depot.optString("productId", "");
|
||||
if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
|
||||
if (!languageCompatible(depot)) continue;
|
||||
owned.put(depot);
|
||||
ownedDepots.add(depot);
|
||||
}
|
||||
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language);
|
||||
cb.onLog("gog: language " + resolved);
|
||||
for (int i = 0; i < ownedDepots.size(); i++) {
|
||||
if (cancelled.get()) return out;
|
||||
JSONObject depot = ownedDepots.get(i);
|
||||
if (!depotSelected(depot, resolved)) continue;
|
||||
String manifestHash = depot.optString("manifest");
|
||||
if (manifestHash == null || manifestHash.isEmpty()) continue;
|
||||
byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
|
||||
@@ -232,6 +250,7 @@ public final class GogDownloadManager {
|
||||
out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
|
||||
out.result.dependencies = dependencies;
|
||||
out.result.dependenciesRead = true;
|
||||
out.result.language = resolved;
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
|
||||
@@ -270,11 +289,31 @@ public final class GogDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean languageCompatible(JSONObject depot) {
|
||||
private static boolean depotSelected(JSONObject depot, String resolved) {
|
||||
JSONArray languages = depot.optJSONArray("languages");
|
||||
if (languages == null || languages.length() == 0) return true;
|
||||
String s = languages.toString();
|
||||
return s.contains("*") || s.contains("en-US") || s.contains("\"en\"") || s.contains("english");
|
||||
String[] listed = new String[languages.length()];
|
||||
for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, "");
|
||||
return StoreLanguage.depotMatchesResolved(listed, resolved);
|
||||
}
|
||||
|
||||
/** Language codes listed on this product's Windows gen2 depots, excluding `*`. */
|
||||
public static List<String> listLanguages(Context ctx, String gameId) {
|
||||
try {
|
||||
String token = GogAuth.validToken(ctx);
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
String buildsJson = httpGet(buildsUrl, null);
|
||||
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
|
||||
if (buildsJson == null) return Collections.emptyList();
|
||||
Callback silent = new Callback() {
|
||||
@Override public void onProgress(String message, int pct) {}
|
||||
};
|
||||
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent);
|
||||
if (gm == null) return Collections.emptyList();
|
||||
return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots"));
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1037,9 +1076,10 @@ public final class GogDownloadManager {
|
||||
JSONArray depots = new JSONObject(manifestStr).optJSONArray("depots");
|
||||
if (depots == null) return -1;
|
||||
long total = 0;
|
||||
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode());
|
||||
for (int i = 0; i < depots.length(); i++) {
|
||||
JSONObject depot = depots.getJSONObject(i);
|
||||
if (languageCompatible(depot)) total += depot.optLong("size", 0);
|
||||
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
|
||||
}
|
||||
return total > 0 ? total : -1;
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -20,6 +21,14 @@ object GogLibrary {
|
||||
private const val LAST_SYNC_KEY = "library_synced_at"
|
||||
private const val SKIPPED_KEY = "library_skipped"
|
||||
private const val THROTTLE_MS = 6L * 60L * 60L * 1000L
|
||||
// mediaType=1 leaves the movies and soundtracks out, hiddenFlag=0 the hidden ones.
|
||||
private const val LIST_URL = "https://embed.gog.com/account/getFilteredProducts?mediaType=1&hiddenFlag=0&sortBy=title"
|
||||
private const val LIST_MAX_PAGES = 100
|
||||
// The listing's image is a bare hash, and the CDN serves it only through Galaxy's own formatters: the
|
||||
// vertical cover is the one card art it carries. The wide background only the per-game fetch knows.
|
||||
private const val COVER_SUFFIX = "_glx_vertical_cover.webp"
|
||||
// The older account endpoint: owned ids only. The fallback for a listing that cannot be read.
|
||||
private const val OWNED_URL = "https://embed.gog.com/user/data/games"
|
||||
|
||||
private val syncing = AtomicBoolean(false)
|
||||
|
||||
@@ -45,42 +54,44 @@ object GogLibrary {
|
||||
|
||||
fun sizeOf(context: Context, gameId: String): Long = GogPrefs.get(context).getLong("size_$gameId", 0L)
|
||||
|
||||
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult {
|
||||
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}, onList: (List<GogGame>) -> Unit = {}, onItems: (List<GogGame>) -> Unit = {}): SyncResult {
|
||||
if (!syncing.compareAndSet(false, true)) return SyncResult.Busy
|
||||
try {
|
||||
val app = context.applicationContext
|
||||
val p = GogPrefs.get(app)
|
||||
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
|
||||
onStatus(app.getString(R.string.stores_sync_game_list))
|
||||
val gamesJson = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token, userAgent = GogAuth.GALAXY_UA)
|
||||
val listing = ownedListing(token)
|
||||
// The listing is the only source of titles: without it the owned ids still sync, just without the early paint.
|
||||
val listed: List<GogGame> = listing?.games
|
||||
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
|
||||
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
|
||||
val ids = ArrayList<String>()
|
||||
val parsed = runCatching {
|
||||
val owned = JSONObject(gamesJson).optJSONArray("owned")
|
||||
if (owned != null) for (i in 0 until owned.length()) {
|
||||
val id = owned.optLong(i).toString()
|
||||
if (id != "1801418160" && id != "0") ids.add(id)
|
||||
}
|
||||
}.isSuccess
|
||||
if (!parsed) return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreadable))
|
||||
|
||||
val ownedSet = ids.toHashSet()
|
||||
val named = listing != null
|
||||
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
|
||||
// A page that failed leaves games unnamed: they are still owned, so their cards and cached details stay.
|
||||
val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }
|
||||
val cachedList = cached(app)
|
||||
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
|
||||
val heavy = force || cachedList.isEmpty() || stale
|
||||
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
|
||||
// A product the last sync found uninstallable stays out of the list while it is re-checked.
|
||||
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
|
||||
if (!heavy) skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
|
||||
val idsToFetch = if (heavy) ids else ids.filter { it !in cachedIds && it !in skipped }
|
||||
val fetchSet = idsToFetch.toHashSet()
|
||||
skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
|
||||
val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped }
|
||||
val merged = LinkedHashMap<String, GogGame>()
|
||||
for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in fetchSet) merged[g.gameId] = g
|
||||
// The cache's own entry stays even when the game is re-fetched: its details beat the listing's.
|
||||
for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in skipped) merged[g.gameId] = g
|
||||
// A game the sync has not reached keeps the listing's entry, so its card is there from the start.
|
||||
for (g in listed) if (g.gameId !in merged && g.gameId !in skipped) merged[g.gameId] = g
|
||||
// Only products that were asked about are cached: a bare listing entry is asked about again.
|
||||
val detailed = HashSet(cachedIds)
|
||||
if (named) onList(sorted(merged))
|
||||
if (idsToFetch.isEmpty()) {
|
||||
val list = merged.values.toList()
|
||||
skipped.retainAll(ownedSet)
|
||||
p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply()
|
||||
saveCache(p, list)
|
||||
return SyncResult.Ok(list.sortedBy { it.title.lowercase() }, 0)
|
||||
saveCache(p, list.filter { it.gameId in detailed })
|
||||
return SyncResult.Ok(sorted(merged), 0)
|
||||
}
|
||||
onStatus(app.resources.getQuantityString(R.plurals.stores_sync_games, idsToFetch.size, idsToFetch.size))
|
||||
val pool = Executors.newFixedThreadPool(5)
|
||||
@@ -89,16 +100,24 @@ object GogLibrary {
|
||||
var fetched = 0
|
||||
for ((idx, f) in futures.withIndex()) {
|
||||
val g = runCatching { f.get() }.getOrNull()
|
||||
if (g != null) { merged[g.gameId] = g; fetched++ }
|
||||
if (g != null) {
|
||||
// The cover the card already shows stays, so the wide art lands over it instead of over a gap.
|
||||
val filled = g.withCover(merged[g.gameId]?.verticalCover)
|
||||
merged[g.gameId] = filled; detailed.add(g.gameId); fetched++; skipped.remove(g.gameId); onItems(listOf(filled))
|
||||
}
|
||||
if ((idx + 1) % 5 == 0) onStatus(app.getString(R.string.stores_sync_progress, idx + 1, futures.size))
|
||||
// Saved as it goes: a sync the user closes the app on still leaves a library to paint next launch.
|
||||
if ((idx + 1) % 20 == 0) saveCache(p, merged.values.filter { it.gameId in detailed })
|
||||
}
|
||||
// A product this round found uninstallable leaves when the round is over, not in the middle of it.
|
||||
for (id in skipped) merged.remove(id)
|
||||
val finalList = merged.values.toList()
|
||||
saveCache(p, finalList)
|
||||
saveCache(p, finalList.filter { it.gameId in detailed })
|
||||
skipped.retainAll(ownedSet)
|
||||
p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply()
|
||||
if (heavy) p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
|
||||
Log.i(TAG, "sync: owned=${ids.size} fetched=$fetched cached=${finalList.size} heavy=$heavy")
|
||||
return SyncResult.Ok(finalList.sortedBy { it.title.lowercase() }, fetched)
|
||||
Log.i(TAG, "sync: owned=${ownedSet.size} fetched=$fetched cached=${finalList.size} heavy=$heavy")
|
||||
return SyncResult.Ok(sorted(merged), fetched)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "sync failed: ${e.message}")
|
||||
return SyncResult.Failed(e.message ?: context.getString(R.string.stores_sync_failed))
|
||||
@@ -107,6 +126,68 @@ object GogLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownedListing(token: String): Listing? {
|
||||
val all = ArrayList<GogGame>()
|
||||
val owned = HashSet<String>()
|
||||
var page = 1
|
||||
var pages = 1
|
||||
while (page <= pages && page <= LIST_MAX_PAGES) {
|
||||
// A page that cannot be read ends the listing; the pages already read still name most of the library.
|
||||
val listed = StoreNet.get("$LIST_URL&page=$page", bearer = token, userAgent = GogAuth.GALAXY_UA)?.let { parseListing(it) }
|
||||
if (listed == null) {
|
||||
StoresState.logLine("GOG: listing page $page of $pages unreadable")
|
||||
// The unread pages hold games too: without their ids those games would leave the library and the cache.
|
||||
owned.addAll(ownedIds(token) ?: return null)
|
||||
break
|
||||
}
|
||||
all.addAll(listed.games)
|
||||
owned.addAll(listed.games.map { it.gameId })
|
||||
pages = listed.totalPages
|
||||
page++
|
||||
}
|
||||
// Not one page read: the caller falls back to the owned ids.
|
||||
return all.ifEmpty { null }?.let { Listing(it, owned) }
|
||||
}
|
||||
|
||||
private fun ownedIds(token: String): List<String>? {
|
||||
val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null
|
||||
val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null
|
||||
val ids = ArrayList<String>(owned.length())
|
||||
for (i in 0 until owned.length()) {
|
||||
val id = owned.optLong(i).toString()
|
||||
if (id != "1801418160" && id != "0") ids.add(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
fun parseListing(json: String): ListingPage? = runCatching {
|
||||
val root = JSONObject(json)
|
||||
val arr = root.optJSONArray("products") ?: return@runCatching null
|
||||
val games = ArrayList<GogGame>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
// 1801418160 is GOG's own "Galaxy" entry, in every library.
|
||||
val id = o.optLong("id", 0L).toString()
|
||||
if (id == "0" || id == "1801418160") continue
|
||||
if (o.optBoolean("isMovie", false) || !o.optBoolean("isGame", true)) continue
|
||||
val title = o.optString("title", "").trim()
|
||||
if (title.isEmpty()) continue
|
||||
val worksOn = o.optJSONObject("worksOn")
|
||||
if (worksOn != null && !worksOn.optBoolean("Windows", true)) continue
|
||||
val released = o.optJSONObject("releaseDate")?.optString("date", "").orEmpty().substringBefore(' ')
|
||||
val image = o.optString("image", "").ifBlank { null }
|
||||
games.add(GogGame(id, title, "", "", "", o.optString("category", ""), 1,
|
||||
image?.let { GogStoreCatalog.absolutize(it + COVER_SUFFIX) }, released, ""))
|
||||
}
|
||||
ListingPage(games, maxOf(1, root.optInt("totalPages", 1)))
|
||||
}.getOrNull()
|
||||
|
||||
class ListingPage(val games: List<GogGame>, val totalPages: Int)
|
||||
|
||||
private class Listing(val games: List<GogGame>, val owned: Set<String>)
|
||||
|
||||
private fun sorted(merged: Map<String, GogGame>): List<GogGame> = merged.values.sortedBy { it.title.lowercase() }
|
||||
|
||||
private fun fetchGame(prefs: SharedPreferences, id: String, token: String, skipped: MutableSet<String>): GogGame? {
|
||||
fun skip(): GogGame? { skipped.add(id); return null }
|
||||
try {
|
||||
@@ -198,6 +279,10 @@ object GogLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
private fun GogGame.withCover(cover: String?): GogGame =
|
||||
if (cover.isNullOrBlank() || cover == verticalCover) this
|
||||
else GogGame(gameId, title, imageUrl, description, developer, category, generation, cover, releaseDate, publisher)
|
||||
|
||||
fun toCatalogItem(context: Context, g: GogGame): CatalogItem = CatalogItem(
|
||||
store = Store.GOG, id = g.gameId, title = g.title,
|
||||
imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null },
|
||||
|
||||
@@ -10,6 +10,14 @@ import java.time.Instant
|
||||
internal object XboxCatalog {
|
||||
private val productId = Regex("[A-Z0-9]{12}")
|
||||
private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
|
||||
/** Plans that include PC games; the retired console-only plan must not offer a Windows install. */
|
||||
private val pcPlans = mapOf(
|
||||
"CFQ7TTC0KGQ8" to "PC Game Pass",
|
||||
"CFQ7TTC0KHS0" to "Game Pass Ultimate",
|
||||
"CFQ7TTC0P85B" to "Xbox Game Pass Premium",
|
||||
"CFQ7TTC0K5DJ" to "Xbox Game Pass Essential",
|
||||
"CFQ7TTC10QFD" to "Xbox Game Pass (Starter Edition)",
|
||||
)
|
||||
|
||||
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map<String, String> {
|
||||
require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
|
||||
@@ -53,7 +61,7 @@ internal object XboxCatalog {
|
||||
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
|
||||
if (parent == null) return if (id in plans) null else "owned"
|
||||
if (parent in blocked) return null
|
||||
if (parent in plans) return if (activePlan(parent)) "gamepass" else null
|
||||
if (parent in plans) return if (parent in pcPlans && activePlan(parent)) "gamepass" else null
|
||||
return kind(parent, visited + id)
|
||||
}
|
||||
val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
|
||||
@@ -110,9 +118,12 @@ internal object XboxCatalog {
|
||||
}
|
||||
}
|
||||
val kind = access[id]
|
||||
val includedWith = objects(localized.optJSONObject("EligibilityProperties")?.optJSONArray("Affirmations"))
|
||||
.mapNotNull { pcPlans[it.optString("AffirmationProductId")] }
|
||||
.firstOrNull().orEmpty()
|
||||
return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"),
|
||||
tallImageUrl = image("TitledPoster", "Poster", "BoxArt"),
|
||||
owned = kind == "owned", subscription = kind == "gamepass",
|
||||
owned = kind == "owned", subscription = kind == "gamepass", includedWith = includedWith,
|
||||
storeUrl = "https://www.xbox.com/games/store/$id",
|
||||
publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"),
|
||||
description = localized.optString("ProductDescription"),
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.stores.xbox
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
@@ -14,7 +15,8 @@ internal object Xodus {
|
||||
const val DIR = "/usr/local/lib/droiddeck-xbox"
|
||||
fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
|
||||
|
||||
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable {
|
||||
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null,
|
||||
private val networkLink: LinuxNetworkLinkComponent) : Closeable {
|
||||
@Synchronized fun send(frame: JSONObject) {
|
||||
process.outputStream.write((frame.toString() + "\n").toByteArray())
|
||||
process.outputStream.flush()
|
||||
@@ -34,6 +36,7 @@ internal object Xodus {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
networkLink.stop()
|
||||
runtimeDirectory?.let { directory ->
|
||||
File(directory, "xodus.sock").delete()
|
||||
directory.delete()
|
||||
@@ -69,8 +72,12 @@ internal object Xodus {
|
||||
val pack = stage(app)
|
||||
val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}"
|
||||
check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." }
|
||||
val networkLink = LinuxNetworkLinkComponent(app, LinuxRuntime.rootDir(app))
|
||||
val credentials = XboxCredentials(app, credentialGeneration)
|
||||
try {
|
||||
networkLink.attach(app)
|
||||
networkLink.publish()
|
||||
networkLink.start()
|
||||
val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() }
|
||||
val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf(
|
||||
"/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off",
|
||||
@@ -92,8 +99,12 @@ internal object Xodus {
|
||||
}
|
||||
val process = builder.start()
|
||||
credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest())
|
||||
return Child(process, credentials, runtimeDirectory, launchDirectory)
|
||||
} catch (e: Exception) { credentials.close(); throw e }
|
||||
return Child(process, credentials, runtimeDirectory, launchDirectory, networkLink)
|
||||
} catch (e: Exception) {
|
||||
runCatching { networkLink.stop() }
|
||||
credentials.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
fun run(app: Context, args: List<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
|
||||
|
||||
@@ -40,6 +40,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import android.content.Context
|
||||
@@ -502,7 +508,7 @@ private fun ArtChooserDialog(
|
||||
sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER
|
||||
}
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
LaunchedEffect(options != null) { focusWithinFrames({ false }) { first } }
|
||||
val apply: (AddedGameArt.Option) -> Unit = { option ->
|
||||
scope.launch {
|
||||
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
|
||||
@@ -624,14 +630,23 @@ private fun SgdbSearchDialog(
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { search() }
|
||||
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) {
|
||||
val firstItem = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val leaveField = { focusManager.clearFocus(); keyboardController?.hide() }
|
||||
val keyboard = LocalInputModeManager.current.inputMode == InputMode.Keyboard
|
||||
LaunchedEffect(picked?.id, matches?.size, images.size, busy) {
|
||||
val ready = if (picked != null) images.isNotEmpty() else !matches.isNullOrEmpty()
|
||||
if (keyboard && !busy && ready) focusSoon { firstItem }
|
||||
}
|
||||
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp, modifier = Modifier.pointerInput(Unit) { detectTapGestures { leaveField() } }) {
|
||||
DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = {
|
||||
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
|
||||
})
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.art_sgdb_search_label)) },
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { leaveField(); search() }),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
|
||||
)
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp
|
||||
@@ -643,24 +658,24 @@ private fun SgdbSearchDialog(
|
||||
val chosenGame = picked
|
||||
val list = matches.orEmpty()
|
||||
if (chosenGame == null) {
|
||||
for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } }
|
||||
list.forEachIndexed { i, match -> key(match.id) { PickerRow(null, match.name, null, focus = if (i == 0) firstItem else null) { leaveField(); showImages(match) } } }
|
||||
if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
|
||||
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
|
||||
}
|
||||
} else {
|
||||
Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (option in images) key(option.full) {
|
||||
images.forEachIndexed { i, option -> key(option.full) {
|
||||
ArtOption(
|
||||
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null,
|
||||
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = if (i == 0) firstItem else null,
|
||||
description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName",
|
||||
) { onPick(option) }
|
||||
}
|
||||
} }
|
||||
}
|
||||
if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
|
||||
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
|
||||
}
|
||||
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null }
|
||||
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { leaveField(); picked = null }
|
||||
}
|
||||
if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
@@ -558,13 +559,37 @@ private fun repoInfo(post: SteamDeckRepo.Post): String =
|
||||
listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "")
|
||||
.filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
|
||||
private const val AFTER_OPEN = 3
|
||||
private const val AFTER_INSTALL = 1
|
||||
private const val AFTER_USE = 2
|
||||
|
||||
/** Install and Use are different buttons in turn, and the button is disabled while installing; it takes focus back for the controller. */
|
||||
@Composable
|
||||
private fun RepoAction(installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit) {
|
||||
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = modifier, onClick = {})
|
||||
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = modifier, onClick = onUse)
|
||||
private fun RepoAction(
|
||||
installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit,
|
||||
takeFocus: Boolean = false,
|
||||
) {
|
||||
val requester = remember { FocusRequester() }
|
||||
val focused = remember { booleanArrayOf(false) }
|
||||
var pending by remember { mutableIntStateOf(if (takeFocus) AFTER_OPEN else 0) }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
LaunchedEffect(installed, inUse, busy, percent != null, pending) {
|
||||
if (pending == 0) return@LaunchedEffect
|
||||
if (inputMode.inputMode != InputMode.Keyboard) { pending = 0; return@LaunchedEffect }
|
||||
focusWithinFrames({ focused[0] }) { requester }
|
||||
when (pending) {
|
||||
AFTER_OPEN -> pending = 0
|
||||
AFTER_INSTALL -> if (installed && focused[0]) pending = 0
|
||||
AFTER_USE -> if (inUse && focused[0]) pending = 0
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val m = modifier.focusRequester(requester).onFocusChanged { focused[0] = it.hasFocus || it.isFocused }
|
||||
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = m, onClick = {})
|
||||
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = m, onClick = { pending = AFTER_USE; onUse() })
|
||||
else SecondaryButton(
|
||||
if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install),
|
||||
enabled = !busy, compact = true, modifier = modifier, onClick = onInstall,
|
||||
enabled = !busy, compact = true, modifier = m, onClick = { pending = AFTER_INSTALL; onInstall() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -576,9 +601,6 @@ private fun PreviewDialog(
|
||||
) {
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
val action = remember { FocusRequester() }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { action } }
|
||||
val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp
|
||||
AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) {
|
||||
DialogTitleRow(post.title, close)
|
||||
@@ -586,7 +608,7 @@ private fun PreviewDialog(
|
||||
MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f))
|
||||
}
|
||||
Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse)
|
||||
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse, takeFocus = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ 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.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -73,6 +74,8 @@ 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.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -381,6 +384,7 @@ fun ComponentsPage(
|
||||
var stackAt by remember { mutableStateOf(Offset.Zero) }
|
||||
var pageAt by remember { mutableStateOf(Offset.Zero) }
|
||||
val landed = remember { mutableStateMapOf<String, Int>() }
|
||||
val nightlyPicked = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val rings = remember { mutableStateMapOf<String, Int>() }
|
||||
fun land(l: String) { landed[l] = (landed[l] ?: 0) + 1; rings[l] = (rings[l] ?: 0) + 1 }
|
||||
val arrivedSeen = remember { intArrayOf(Hops.arrived) }
|
||||
@@ -504,7 +508,7 @@ fun ComponentsPage(
|
||||
val availableTitle: @Composable (String) -> String = { l ->
|
||||
when (l) {
|
||||
PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null || it.update != null })
|
||||
else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size)
|
||||
else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size)
|
||||
}
|
||||
}
|
||||
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
|
||||
@@ -581,12 +585,16 @@ fun ComponentsPage(
|
||||
) { protonActions.onInstall(row.id) }
|
||||
}
|
||||
else -> {
|
||||
val available = availableFor(l, catalog, snapshot)
|
||||
val available = availableRows(l, catalog, snapshot)
|
||||
if (available.isEmpty()) EmptyLine(if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new))
|
||||
for (d in available) GetLine(
|
||||
d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0),
|
||||
progress = downloads[d.file], enabled = busy == null,
|
||||
) { onDownload(d) }
|
||||
for ((stable, nightly) in available) {
|
||||
val d = if (nightly != null && nightlyPicked[stable.file] == true) nightly else stable
|
||||
GetLine(
|
||||
d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0),
|
||||
progress = downloads[d.file], enabled = busy == null,
|
||||
channel = if (nightly != null) ({ ChannelToggle(d === nightly, enabled = busy == null) { nightlyPicked[stable.file] = d !== nightly } }) else null,
|
||||
) { onDownload(d) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -922,6 +930,14 @@ private fun availableFor(l: String, catalog: List<CatalogItem>, snapshot: Snapsh
|
||||
return catalog.filter { it.comp == l && ComponentsManager.safeName(it.file) !in stored }
|
||||
}
|
||||
|
||||
// The rows to show: each stable row carries its paired nightly (if any), and a paired nightly has no row of its own.
|
||||
private fun availableRows(l: String, catalog: List<CatalogItem>, snapshot: Snapshot?): List<Pair<CatalogItem, CatalogItem?>> {
|
||||
val available = availableFor(l, catalog, snapshot)
|
||||
val pairs = ComponentsManager.nightlyPairs(available)
|
||||
val paired = pairs.values.toSet()
|
||||
return available.filter { it !in paired }.map { it to pairs[it] }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LayerTitle(l: String): String = when (l) {
|
||||
GPU_TAB -> stringResource(R.string.comp_gpu_drivers)
|
||||
@@ -1417,7 +1433,8 @@ private fun ChoiceLine(
|
||||
@Composable
|
||||
private fun GetLine(
|
||||
name: String, detail: String, progress: Int?, enabled: Boolean,
|
||||
badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null, onGet: () -> Unit,
|
||||
badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null,
|
||||
channel: (@Composable () -> Unit)? = null, onGet: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -1432,12 +1449,43 @@ private fun GetLine(
|
||||
if (progress != null) ProgressBit(progress)
|
||||
else {
|
||||
if (onCancel != null) FocusText(stringResource(R.string.proton_cancel_queued), pal.signal, onClick = onCancel)
|
||||
channel?.invoke()
|
||||
SmallButton(action ?: stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet)
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
// Stable | Nightly in the same box as SmallButton: one focus stop, A (or a tap) flips it.
|
||||
@Composable
|
||||
private fun ChannelToggle(nightly: Boolean, enabled: Boolean, onFlip: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src) && enabled
|
||||
val state = stringResource(if (nightly) R.string.comp_channel_nightly else R.string.comp_channel_stable)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(colors.surfaceVariant)
|
||||
.glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp)
|
||||
.hoverable(src).toggleable(value = nightly, interactionSource = src, indication = null, enabled = enabled, role = Role.Switch, onValueChange = { onFlip() })
|
||||
.controllerConfirm(enabled = enabled, onClick = onFlip)
|
||||
.semantics { stateDescription = state }
|
||||
.padding(4.dp),
|
||||
) {
|
||||
for ((label, on) in listOf(R.string.comp_channel_stable to !nightly, R.string.comp_channel_nightly to nightly)) {
|
||||
Text(
|
||||
stringResource(label), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (!enabled) colors.onSurfaceVariant else if (on) (if (hot) pal.onSignal else pal.signal) else colors.onSurfaceVariant,
|
||||
modifier = Modifier.clip(RoundedCornerShape(7.dp))
|
||||
.background(if (on && enabled) (if (hot) pal.signal else pal.signal.copy(alpha = 0.16f)) else Color.Transparent)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailLine(detail: String, badge: String?) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
@@ -49,13 +50,6 @@ class DeckControlsPanel(
|
||||
select(if (pad != null) selectedWithPad else selected)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) {
|
||||
post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
DeckControls.releaseAll()
|
||||
super.onDetachedFromWindow()
|
||||
@@ -85,8 +79,10 @@ class DeckControlsPanel(
|
||||
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
}
|
||||
val centre = FrameLayout(context).apply {
|
||||
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER))
|
||||
val centre = HorizontalScrollView(context).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44)))
|
||||
}
|
||||
addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
|
||||
addView(roundButton("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu, wide = true))
|
||||
@@ -102,6 +98,17 @@ class DeckControlsPanel(
|
||||
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
|
||||
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
|
||||
}
|
||||
tabViews[tab]?.let { selectedView ->
|
||||
(selectedView.parent?.parent as? HorizontalScrollView)?.post {
|
||||
val left = selectedView.left
|
||||
val right = selectedView.right
|
||||
val scroll = selectedView.parent?.parent as? HorizontalScrollView ?: return@post
|
||||
when {
|
||||
left < scroll.scrollX -> scroll.smoothScrollTo(left, 0)
|
||||
right > scroll.scrollX + scroll.width -> scroll.smoothScrollTo(right - scroll.width, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
content.removeAllViews()
|
||||
topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE
|
||||
content.addView(
|
||||
@@ -643,7 +650,7 @@ class DeckControlsPanel(
|
||||
private const val CLICK_MS = 300L
|
||||
|
||||
private var selected = Tab.BOTH
|
||||
private var selectedWithPad = Tab.GAMEPAD
|
||||
private var selectedWithPad = Tab.BOTH
|
||||
|
||||
private const val CORNER = 20f
|
||||
private const val CORNER_PILL = 22f
|
||||
|
||||
@@ -268,6 +268,21 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA
|
||||
}
|
||||
}
|
||||
|
||||
/** A skipped sync is not a success: "0 files" used to show for a signed-out store or cloud saves turned off. */
|
||||
internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.launcher.stores.CloudSaves.Result): String = when (r.result) {
|
||||
"ok" -> when {
|
||||
r.conflicts > 0 -> context.getString(R.string.cloud_conflict_files, r.conflicts)
|
||||
r.files == 0 -> context.getString(R.string.cloud_up_to_date)
|
||||
else -> context.getString(R.string.cloud_done, r.files)
|
||||
}
|
||||
"skipped" -> context.getString(when (r.reason) {
|
||||
"not-signed-in" -> R.string.cloud_signed_out
|
||||
"no-cloud-saves" -> R.string.cloud_none
|
||||
else -> R.string.cloud_not_synced
|
||||
})
|
||||
else -> context.getString(R.string.cloud_not_synced)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compose.ui.focus.FocusRequester) {
|
||||
val store = com.droiddeck.launcher.stores.Store.byId(g.source)?.takeIf { it != com.droiddeck.launcher.stores.Store.AMAZON } ?: return
|
||||
@@ -290,7 +305,7 @@ private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compo
|
||||
busy = if (up) "up" else "down"
|
||||
Thread({
|
||||
val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id, force) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id, force)
|
||||
com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, if (r.ok) context.getString(R.string.cloud_done, r.files) else r.reason, android.widget.Toast.LENGTH_SHORT).show() }
|
||||
com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, cloudMessage(context, r), android.widget.Toast.LENGTH_SHORT).show() }
|
||||
}, "cloud-manual").start()
|
||||
}
|
||||
MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, focusRequester = first,
|
||||
@@ -302,7 +317,7 @@ private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compo
|
||||
com.droiddeck.launcher.stores.StoresState.post { enabled = written?.cloud ?: enabled }
|
||||
}, "cloud-switch").start()
|
||||
}
|
||||
val ready = st != null && supported && busy == null
|
||||
val ready = st != null && supported && enabled && busy == null
|
||||
val pending = stringResource(R.string.cloud_pending)
|
||||
val summary = when {
|
||||
st == null || busy != null -> pending
|
||||
|
||||
@@ -132,7 +132,7 @@ class FrontEndState(
|
||||
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
|
||||
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
|
||||
val showPhantomGate: Boolean = false,
|
||||
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
|
||||
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
|
||||
val launcherFullscreen: Boolean = true,
|
||||
val downloadSource: String = DownloadSource.MODE_AUTO,
|
||||
val downloadMirror: String = "",
|
||||
|
||||
@@ -79,6 +79,33 @@ internal object GlassesTab {
|
||||
const val VR = "vr"
|
||||
const val HARDWARE = "hardware"
|
||||
const val TRACKING = "tracking"
|
||||
const val CONTROLLERS = "controllers"
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun VrControllerGroups(host: MenuHost, track: (String) -> Modifier = { Modifier }) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { com.droiddeck.launcher.input.VrControllers.load(context) }
|
||||
val c by com.droiddeck.launcher.input.VrControllers.settings.collectAsState()
|
||||
val emulate = com.droiddeck.launcher.input.VrControllers.Emulate.entries
|
||||
SettingsGroup(stringResource(R.string.vr_controllers_group)) {
|
||||
ChoiceRow(host, "vr-emulate", stringResource(R.string.vr_controllers_emulate), stringResource(R.string.vr_controllers_emulate_hint),
|
||||
emulate.map { it to stringResource(when (it) {
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_off
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.INDEX -> R.string.vr_controllers_index
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.TOUCH -> R.string.vr_controllers_touch
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_vive
|
||||
}) }, c.emulate, chipModifier = track("vr-emulate")) { com.droiddeck.launcher.input.VrControllers.setEmulate(context, it) }
|
||||
if (c.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ChoiceRow(host, "vr-sticks",
|
||||
stringResource(R.string.vr_controllers_sticks), stringResource(R.string.vr_controllers_sticks_hint),
|
||||
listOf(false to stringResource(R.string.vr_controllers_sticks_game), true to stringResource(R.string.vr_controllers_sticks_hands)),
|
||||
c.sticksMoveHands, chipModifier = track("vr-sticks")) { com.droiddeck.launcher.input.VrControllers.setSticksMoveHands(context, it) }
|
||||
SettingsRow(stringResource(R.string.vr_controllers_buttons), stringResource(when (c.emulate) {
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_buttons_off
|
||||
com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_buttons_vive
|
||||
else -> R.string.vr_controllers_buttons_stick
|
||||
}), hintLines = 6) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -362,13 +389,3 @@ private fun GlassesRecenterAction(label: String, tag: String, tint: Int, opacity
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GlassesPlayingHint(model: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(56.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.45f), fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/** Actions for the phone while the picture is on the glasses. Null hides the button. */
|
||||
internal class GlassesPhoneActions(
|
||||
val onRecenter: (() -> Unit)?,
|
||||
val onRecenterJoyCons: (() -> Unit)?,
|
||||
val onSteamVrMenu: (() -> Unit)?,
|
||||
val onSwitchSticks: (() -> Unit)?,
|
||||
val controllers: String?,
|
||||
val onNextControllers: (() -> Unit)?,
|
||||
val onKeyboard: () -> Unit,
|
||||
val onSessionMenu: () -> Unit,
|
||||
val onStopSteamVr: (() -> Unit)?,
|
||||
val brightness: Int,
|
||||
val brightnessMax: Int,
|
||||
val onBrightness: ((Int) -> Unit)?,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun GlassesPhonePanel(model: String, status: String?, a: GlassesPhoneActions) {
|
||||
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.displayCutout).padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(max = 640.dp)) {
|
||||
Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(40.dp))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.6f), fontSize = 15.sp)
|
||||
if (status != null) Text(status, color = Color.White.copy(alpha = 0.4f), fontSize = 13.sp, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
a.onRecenter?.let { PanelButton(stringResource(R.string.glasses_phone_recenter), "phone-recenter", it) }
|
||||
a.onRecenterJoyCons?.let { PanelButton(stringResource(R.string.glasses_recenter_joycons_button), "phone-recenter-joycons", it) }
|
||||
a.onSteamVrMenu?.let { PanelButton(stringResource(R.string.glasses_phone_steamvr_menu), "phone-steamvr-menu", it) }
|
||||
a.onNextControllers?.let { next -> a.controllers?.let {
|
||||
PanelButton(stringResource(R.string.glasses_phone_controllers, it), "phone-controllers", next) } }
|
||||
a.onSwitchSticks?.let { PanelButton(stringResource(R.string.glasses_phone_switch_sticks), "phone-switch-sticks", it) }
|
||||
PanelButton(stringResource(R.string.glasses_phone_keyboard), "phone-keyboard", a.onKeyboard)
|
||||
PanelButton(stringResource(R.string.glasses_phone_session_menu), "phone-session-menu", a.onSessionMenu)
|
||||
a.onStopSteamVr?.let { PanelButton(stringResource(R.string.glasses_phone_stop_steamvr), "phone-stop-steamvr", it) }
|
||||
}
|
||||
val setBrightness = a.onBrightness
|
||||
if (setBrightness != null && a.brightnessMax > 0) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PanelButton("−", "phone-brightness-down") { setBrightness((a.brightness - 1).coerceAtLeast(0)) }
|
||||
Text(stringResource(R.string.glasses_phone_brightness, a.brightness.coerceAtLeast(0), a.brightnessMax),
|
||||
color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp)
|
||||
PanelButton("+", "phone-brightness-up") { setBrightness((a.brightness + 1).coerceAtMost(a.brightnessMax)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelButton(label: String, tag: String, onClick: () -> Unit) =
|
||||
SecondaryButton(label, compact = true, modifier = Modifier.testTag(tag), onClick = onClick)
|
||||
@@ -3,6 +3,9 @@ package com.droiddeck.launcher.ui
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -105,7 +108,7 @@ private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { MutableInteractionSource() }
|
||||
@@ -113,7 +116,7 @@ private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.clip(shape)
|
||||
modifier = Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(shape)
|
||||
.background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
|
||||
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
@@ -140,6 +143,9 @@ internal fun LogoPositionDialog(
|
||||
val shown = rememberShown(onBack)
|
||||
val back = { shown.targetState = false }
|
||||
var value by remember { mutableStateOf(start ?: LogoPosition.DEFAULT) }
|
||||
val startPin = remember { value.pin }
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusSoon { first } }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val save: (LogoPosition.Value?) -> Unit = { next ->
|
||||
@@ -172,7 +178,7 @@ internal fun LogoPositionDialog(
|
||||
)
|
||||
Text(stringResource(R.string.logo_pos_position), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin) { value = value.copy(pin = pin) }
|
||||
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin, if (pin == startPin) first else null) { value = value.copy(pin = pin) }
|
||||
}
|
||||
ValueSlider(
|
||||
value.widthPct, LogoPosition.MIN_PCT..LogoPosition.MAX_PCT, step = 1, enabled = true, label = stringResource(R.string.logo_pos_size),
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -81,6 +82,8 @@ class ModeSettings(
|
||||
val picturePosition: String? = null,
|
||||
val stretch16x9: Boolean? = null,
|
||||
val steamClientArch: String? = null,
|
||||
/** Steam only: a game launched from the front end returns there when it closes; null hides the row. */
|
||||
val returnAfterGame: Boolean? = null,
|
||||
val steamRepairQueued: Boolean = false,
|
||||
val mangoapp: Boolean = true,
|
||||
val steamController: String? = null,
|
||||
@@ -98,6 +101,7 @@ class ModeSettings(
|
||||
val wifiDiscoveryBlocked: Boolean = false,
|
||||
val deckyInstalled: String? = null,
|
||||
val deckyLatestRelease: DeckyManager.Release? = null,
|
||||
val deckyBranch: Int = DeckyManager.BRANCH_STABLE,
|
||||
val deckyChecking: Boolean = false,
|
||||
val deckyStage: String? = null,
|
||||
val deckyPercent: Int = -1,
|
||||
@@ -133,6 +137,7 @@ class ModeSettingsActions(
|
||||
val onSteamRepair: () -> Unit = {},
|
||||
val onSteamClientArch: (String) -> Unit = {},
|
||||
val onMangoapp: (Boolean) -> Unit = {},
|
||||
val onReturnAfterGame: (Boolean) -> Unit = {},
|
||||
val onSteamController: (String) -> Unit = {},
|
||||
val onUnfoldedControls: (Boolean) -> Unit = {},
|
||||
val onRunSteamAtStartup: (Boolean) -> Unit = {},
|
||||
@@ -144,6 +149,7 @@ class ModeSettingsActions(
|
||||
val onWifiDiscoverySettings: () -> Unit = {},
|
||||
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
|
||||
val onDeckyCheck: () -> Unit = {},
|
||||
val onDeckyBranch: (Int) -> Unit = {},
|
||||
val onDeckyEnabled: (Boolean) -> Unit = {},
|
||||
val onDeckyUninstall: () -> Unit = {},
|
||||
val onPickDeckyPluginZip: () -> Unit = {},
|
||||
@@ -273,8 +279,10 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
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),
|
||||
SessionPrefs.CONTROLLER_NONE to stringResource(R.string.second_mode_none),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
note = if (s.steamController == SessionPrefs.CONTROLLER_NONE) stringResource(R.string.common_applies_next_session)
|
||||
else stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam && s.unfoldedControls != null) ToggleRow(
|
||||
@@ -423,6 +431,19 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
|
||||
}
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "decky-channel", stringResource(R.string.decky_channel),
|
||||
stringResource(R.string.decky_channel_hint),
|
||||
listOf(
|
||||
DeckyManager.BRANCH_STABLE to stringResource(R.string.decky_channel_stable),
|
||||
DeckyManager.BRANCH_PRERELEASE to stringResource(R.string.decky_channel_prerelease),
|
||||
) + if (s.deckyBranch == DeckyManager.BRANCH_TESTING) listOf(
|
||||
DeckyManager.BRANCH_TESTING to stringResource(R.string.decky_channel_testing),
|
||||
) else emptyList(),
|
||||
s.deckyBranch,
|
||||
enabled = s.deckyStage == null && !s.deckySessionRunning,
|
||||
onPick = a.onDeckyBranch,
|
||||
)
|
||||
if (s.deckyInstalled != null) ToggleRow(
|
||||
host, "decky-enabled", stringResource(R.string.mode_decky),
|
||||
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
|
||||
@@ -454,6 +475,11 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
if (s.returnAfterGame != null) ToggleRow(
|
||||
host, "returnaftergame", stringResource(R.string.mode_return_after_game),
|
||||
stringResource(R.string.mode_return_after_game_hint),
|
||||
s.returnAfterGame, onChange = a.onReturnAfterGame,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
@@ -529,6 +555,35 @@ internal fun ProtonOptions(
|
||||
GameEnvironmentRow()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeckyZipWarningDialog(onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
var left by remember { mutableIntStateOf(if (SessionPrefs.deckyZipWarningSeen(context)) 0 else DECKY_ZIP_WARNING_SECONDS) }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
while (left > 0) {
|
||||
kotlinx.coroutines.delay(1000L)
|
||||
left--
|
||||
}
|
||||
SessionPrefs.setDeckyZipWarningSeen(context)
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(stringResource(R.string.decky_zip_warning_title)) },
|
||||
text = { Text(stringResource(R.string.decky_zip_warning_text)) },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onConfirm, enabled = left == 0) {
|
||||
Text(
|
||||
if (left > 0) androidx.compose.ui.res.pluralStringResource(R.plurals.decky_zip_warning_wait, left, left)
|
||||
else stringResource(R.string.decky_zip_warning_continue),
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = { androidx.compose.material3.TextButton(onClick = onCancel) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
private const val DECKY_ZIP_WARNING_SECONDS = 5
|
||||
|
||||
@Composable
|
||||
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
|
||||
+52
-11
@@ -1,17 +1,21 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import android.app.Presentation
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
@@ -33,9 +37,9 @@ import com.termux.terminal.TerminalSessionClient
|
||||
import com.termux.view.TerminalView
|
||||
import com.termux.view.TerminalViewClient
|
||||
|
||||
internal class SecondScreenControls(
|
||||
context: Context,
|
||||
class SecondScreenPresentation(
|
||||
private val sessionActivity: SessionActivity,
|
||||
display: Display,
|
||||
private val sendPointer: (x: Float, y: Float, width: Float, height: Float) -> Unit,
|
||||
private val sendButton: (button: Int, pressed: Boolean) -> Unit,
|
||||
private val sendWheel: (steps: Int) -> Unit,
|
||||
@@ -43,8 +47,12 @@ internal class SecondScreenControls(
|
||||
private val onQam: () -> Unit,
|
||||
private val onClose: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
) : LinearLayout(context) {
|
||||
private val root get() = this
|
||||
) : Presentation(sessionActivity, display) {
|
||||
private val root = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(Color.rgb(15, 18, 22))
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
private var currentMode = SecondScreenMode.NONE
|
||||
private var keyboardHost: KeyboardHost? = null
|
||||
private var terminalSession: TerminalSession? = null
|
||||
@@ -52,22 +60,35 @@ internal class SecondScreenControls(
|
||||
private var terminalPid = -1
|
||||
private var ctrlActive = false
|
||||
private var altActive = false
|
||||
private var created = false
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
setBackgroundColor(Color.rgb(15, 18, 22))
|
||||
isFocusableInTouchMode = true
|
||||
setCancelable(false)
|
||||
setCanceledOnTouchOutside(false)
|
||||
setOnDismissListener { closeTerminal() }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
created = true
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
setContentView(root)
|
||||
applyImmersiveMode()
|
||||
renderMode()
|
||||
}
|
||||
|
||||
fun showMode(mode: SecondScreenMode) {
|
||||
if (currentMode == mode) return
|
||||
if (currentMode == mode && isShowing) return
|
||||
currentMode = mode
|
||||
renderMode()
|
||||
if (created) renderMode()
|
||||
}
|
||||
|
||||
fun closeControls() {
|
||||
closeTerminal()
|
||||
keyboardHost?.hide()
|
||||
keyboardHost = null
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
@@ -77,14 +98,24 @@ internal class SecondScreenControls(
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
applyImmersiveMode()
|
||||
sessionActivity.refreshClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderMode() {
|
||||
keyboardHost?.hide()
|
||||
keyboardHost = null
|
||||
closeTerminal()
|
||||
root.removeAllViews()
|
||||
if (currentMode == SecondScreenMode.NONE) return
|
||||
val mode = if (currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad) SecondScreenMode.NONE
|
||||
else currentMode
|
||||
if (mode == SecondScreenMode.NONE) return
|
||||
|
||||
when (currentMode) {
|
||||
when (mode) {
|
||||
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
|
||||
SecondScreenMode.TERMINAL -> renderTerminal()
|
||||
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams())
|
||||
@@ -262,6 +293,16 @@ internal class SecondScreenControls(
|
||||
|
||||
private fun weightParams() = LinearLayout.LayoutParams(-1, 0, 1f)
|
||||
|
||||
private fun applyImmersiveMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
window?.decorView?.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
}
|
||||
|
||||
private inner class TrackpadView(context: Context) : View(context) {
|
||||
private val gestures = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(),
|
||||
object : PointerGestures.Listener {
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Display
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import com.droiddeck.launcher.SessionActivity
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class SecondScreenWindow(
|
||||
private val sessionActivity: SessionActivity,
|
||||
val display: Display,
|
||||
private val sendPointer: (Float, Float, Float, Float) -> Unit,
|
||||
private val sendButton: (Int, Boolean) -> Unit,
|
||||
private val sendWheel: (Int) -> Unit,
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val onClose: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
) {
|
||||
private val token = tokens.incrementAndGet()
|
||||
private var activity = WeakReference<SecondScreenActivity>(null)
|
||||
private var controls: SecondScreenControls? = null
|
||||
private var mode = SecondScreenMode.NONE
|
||||
private var launching = false
|
||||
private var closed = false
|
||||
val window get() = activity.get()?.window
|
||||
val isShowing get() = !closed && (launching || activity.get()?.isFinishing == false)
|
||||
|
||||
fun show() {
|
||||
if (isShowing || closed) return
|
||||
owners[token] = WeakReference(this)
|
||||
launching = true
|
||||
try {
|
||||
val intent = Intent(sessionActivity, SecondScreenActivity::class.java)
|
||||
.putExtra(OWNER, token)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
sessionActivity.startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(display.displayId).toBundle())
|
||||
} catch (error: Exception) {
|
||||
launching = false
|
||||
owners.remove(token)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fun showMode(mode: SecondScreenMode) {
|
||||
this.mode = mode
|
||||
controls?.showMode(mode)
|
||||
}
|
||||
|
||||
fun closeControls() {
|
||||
closed = true
|
||||
launching = false
|
||||
owners.remove(token)
|
||||
controls?.closeControls()
|
||||
controls = null
|
||||
activity.get()?.finishAndRemoveTask()
|
||||
activity.clear()
|
||||
}
|
||||
|
||||
internal fun attach(host: SecondScreenActivity): View {
|
||||
controls?.closeControls()
|
||||
activity = WeakReference(host)
|
||||
launching = false
|
||||
return SecondScreenControls(host, sessionActivity, sendPointer, sendButton, sendWheel,
|
||||
onSteamMenu, onQam, onClose, pad).also {
|
||||
controls = it
|
||||
it.showMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun detach(host: SecondScreenActivity) {
|
||||
if (activity.get() !== host) return
|
||||
controls?.closeControls()
|
||||
controls = null
|
||||
activity.clear()
|
||||
if (host.isFinishing && !closed) onClose()
|
||||
}
|
||||
|
||||
internal fun refreshClipboard() = sessionActivity.refreshClipboard()
|
||||
|
||||
companion object {
|
||||
private const val OWNER = "secondScreenOwner"
|
||||
private val tokens = AtomicLong()
|
||||
private val owners = mutableMapOf<Long, WeakReference<SecondScreenWindow>>()
|
||||
internal fun find(intent: Intent): SecondScreenWindow? =
|
||||
owners[intent.getLongExtra(OWNER, 0)]?.get()?.takeUnless { it.closed }
|
||||
}
|
||||
}
|
||||
|
||||
class SecondScreenActivity : Activity() {
|
||||
private var owner: SecondScreenWindow? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val host = SecondScreenWindow.find(intent)
|
||||
if (host == null || !SessionState.running) {
|
||||
finishAndRemoveTask()
|
||||
return
|
||||
}
|
||||
owner = host
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
setContentView(host.attach(this))
|
||||
applyImmersiveMode()
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
applyImmersiveMode()
|
||||
owner?.refreshClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Back is handled by the session controls")
|
||||
override fun onBackPressed() = Unit
|
||||
|
||||
override fun onDestroy() {
|
||||
owner?.detach(this)
|
||||
owner = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun applyImmersiveMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -68,6 +69,8 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -78,21 +81,93 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
|
||||
@Composable
|
||||
fun HudText(text: String) {
|
||||
fun HudText(text: String, graph: FloatArray = FloatArray(0), color: String = "mint", opacity: Int = 70) {
|
||||
if (text.isEmpty() && graph.size < 2) return
|
||||
val accent = hudInk(color)
|
||||
val wash = (opacity.coerceIn(30, 100) / 100f)
|
||||
val ink = accent.copy(alpha = (0.5f + wash * 0.5f).coerceIn(0.62f, 1f))
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
|
||||
Text(
|
||||
text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.background(Color(0x8C000000))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(accent.copy(alpha = wash * 0.28f), Color.Black.copy(alpha = wash * 0.72f)),
|
||||
),
|
||||
RoundedCornerShape(8.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
) {
|
||||
if (text.isNotEmpty()) Text(
|
||||
text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = ink,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
if (graph.size >= 2) {
|
||||
if (text.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
||||
HudGraph(graph, accent, wash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HudGraph(samples: FloatArray, accent: Color, wash: Float) {
|
||||
Canvas(Modifier.width(148.dp).height(44.dp)) {
|
||||
val maxMs = (samples.maxOrNull() ?: 16.7f).coerceAtLeast(16.7f)
|
||||
val step = size.width / (samples.size - 1)
|
||||
fun yOf(ms: Float) = size.height * (1f - (ms / maxMs).coerceIn(0f, 1f))
|
||||
val fillAlpha = wash * 0.5f
|
||||
val fill = Path().apply {
|
||||
moveTo(0f, size.height)
|
||||
samples.forEachIndexed { i, ms -> lineTo(i * step, yOf(ms)) }
|
||||
lineTo(size.width, size.height)
|
||||
close()
|
||||
}
|
||||
drawPath(
|
||||
fill,
|
||||
Brush.verticalGradient(listOf(HUD_SLOW.copy(alpha = fillAlpha), accent.copy(alpha = fillAlpha * 0.45f))),
|
||||
)
|
||||
val guide = yOf(16.7f)
|
||||
drawLine(Color.White.copy(alpha = 0.32f), Offset(0f, guide), Offset(size.width, guide), strokeWidth = 1f)
|
||||
for (i in 0 until samples.lastIndex) {
|
||||
val tone = ((samples[i] - 8f) / 25f).coerceIn(0f, 1f)
|
||||
drawLine(
|
||||
mix(accent, HUD_SLOW, tone).copy(alpha = 0.55f + wash * 0.4f),
|
||||
Offset(i * step, yOf(samples[i])),
|
||||
Offset((i + 1) * step, yOf(samples[i + 1])),
|
||||
strokeWidth = 2.5f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hudInk(color: String): Color = Color(
|
||||
when (color) {
|
||||
"white" -> 0xFFF2F5F8
|
||||
"sky" -> 0xFF66C0F4
|
||||
"amber" -> 0xFFFFB74D
|
||||
"violet" -> 0xFFD0A2FF
|
||||
else -> 0xFF3DDC84
|
||||
}.toInt(),
|
||||
)
|
||||
|
||||
private fun mix(from: Color, to: Color, t: Float): Color {
|
||||
val u = t.coerceIn(0f, 1f)
|
||||
return Color(
|
||||
red = from.red + (to.red - from.red) * u,
|
||||
green = from.green + (to.green - from.green) * u,
|
||||
blue = from.blue + (to.blue - from.blue) * u,
|
||||
alpha = 1f,
|
||||
)
|
||||
}
|
||||
|
||||
private val HUD_SLOW = Color(0xFFFF5C6C.toInt())
|
||||
|
||||
private class LoadStage(@androidx.annotation.StringRes val label: Int, @androidx.annotation.StringRes val detail: Int)
|
||||
|
||||
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
|
||||
|
||||
@@ -110,6 +110,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -195,6 +196,11 @@ class DrawerActions(
|
||||
val isHomeApp: Boolean,
|
||||
val androidApps: List<HomeApp.LaunchableApp>,
|
||||
val hudOn: Boolean,
|
||||
val hudFps: Boolean = true,
|
||||
val hudBattery: Boolean = true,
|
||||
val hudGraph: Boolean = true,
|
||||
val hudColor: String = SessionPrefs.HUD_COLOR_MINT,
|
||||
val hudOpacity: Int = SessionPrefs.HUD_OPACITY_DEFAULT,
|
||||
val frameGen: FrameGen.Mode,
|
||||
val lossless: Lossless.State,
|
||||
val oscMode: String,
|
||||
@@ -222,6 +228,11 @@ class DrawerActions(
|
||||
val secondScreenDisplays: List<SecondScreenDisplay>,
|
||||
val selectedSecondScreenDisplay: Int,
|
||||
val onHud: (Boolean) -> Unit,
|
||||
val onHudFps: (Boolean) -> Unit = {},
|
||||
val onHudBattery: (Boolean) -> Unit = {},
|
||||
val onHudGraph: (Boolean) -> Unit = {},
|
||||
val onHudColor: (String) -> Unit = {},
|
||||
val onHudOpacity: (Int) -> Unit = {},
|
||||
val onFrameGenPick: (FrameGen.Mode) -> Unit,
|
||||
val onImportLossless: () -> Unit,
|
||||
val onKeyboard: () -> Unit,
|
||||
@@ -308,7 +319,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
val pal = LocalPalette.current
|
||||
val host = rememberMenuHost()
|
||||
val (glasses, glassesSettings) = rememberGlasses()
|
||||
val glassesPages = glassesTabs(glasses)
|
||||
val glassesPages = glassesTabs(glasses) + (GlassesTab.CONTROLLERS to stringResource(R.string.vr_controllers_tab))
|
||||
var selectedGlassesTab by rememberSaveable { mutableStateOf(GlassesTab.DISPLAY) }
|
||||
val glassesTab = selectedGlassesTab.takeIf { key -> glassesPages.any { it.first == key } } ?: GlassesTab.DISPLAY
|
||||
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -462,6 +473,30 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
SettingsGroup(stringResource(R.string.drawer_page_display)) {
|
||||
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
|
||||
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
|
||||
if (a.hudOn) {
|
||||
ToggleRow(host, "hud-fps", stringResource(R.string.drawer_hud_fps), null, a.hudFps,
|
||||
chipModifier = focus.track(page, "hud-fps"), onChange = a.onHudFps)
|
||||
ToggleRow(host, "hud-battery", stringResource(R.string.drawer_hud_battery), null, a.hudBattery,
|
||||
chipModifier = focus.track(page, "hud-battery"), onChange = a.onHudBattery)
|
||||
ToggleRow(host, "hud-graph", stringResource(R.string.drawer_hud_graph), null, a.hudGraph,
|
||||
chipModifier = focus.track(page, "hud-graph"), onChange = a.onHudGraph)
|
||||
ChoiceRow(
|
||||
host, "hud-color", stringResource(R.string.drawer_hud_color), null,
|
||||
listOf(
|
||||
SessionPrefs.HUD_COLOR_MINT to stringResource(R.string.drawer_hud_color_mint),
|
||||
"white" to stringResource(R.string.drawer_hud_color_white),
|
||||
"sky" to stringResource(R.string.drawer_hud_color_sky),
|
||||
"amber" to stringResource(R.string.drawer_hud_color_amber),
|
||||
"violet" to stringResource(R.string.drawer_hud_color_violet),
|
||||
),
|
||||
a.hudColor, chipModifier = focus.track(page, "hud-color"), onPick = a.onHudColor,
|
||||
)
|
||||
SliderRow(
|
||||
stringResource(R.string.drawer_hud_opacity), null, a.hudOpacity,
|
||||
SessionPrefs.HUD_OPACITY_RANGE, step = 10, format = { "$it%" },
|
||||
modifier = focus.track(page, "hud-opacity"), onChange = a.onHudOpacity,
|
||||
)
|
||||
}
|
||||
val fgOpen = host.open == "fg"
|
||||
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
|
||||
@@ -557,7 +592,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
SessionDrawerPage.SECOND_SCREEN -> {
|
||||
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.GLASSES -> {
|
||||
SessionDrawerPage.GLASSES -> if (glassesTab == GlassesTab.CONTROLLERS) {
|
||||
VrControllerGroups(host) { key -> focus.track(page, key) }
|
||||
} else {
|
||||
GlassesGroups(host, glasses, glassesSettings, { key -> focus.track(page, key) }, onRecenter = a.onGlassesRecenter,
|
||||
tab = glassesTab, outputActive = a.glassesOutput, steamVrRunning = a.steamVr == SteamVr.Status.RUNNING,
|
||||
steamVr = { SteamVrRow(a) })
|
||||
@@ -1379,6 +1416,7 @@ internal fun ComponentDownloadsDrawerGroup(
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val installed = packages.map { it.file }.toSet()
|
||||
val nightlyPicked = remember { mutableStateMapOf<String, Boolean>() }
|
||||
SettingsGroup(stringResource(R.string.drawer_component_downloads)) {
|
||||
Text(stringResource(R.string.drawer_component_downloads_hint), fontSize = 12.sp,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
|
||||
@@ -1402,21 +1440,40 @@ internal fun ComponentDownloadsDrawerGroup(
|
||||
}
|
||||
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null },
|
||||
title = ComponentsManager.LABEL.getValue(comp)) { firstItemFocus ->
|
||||
val firstAvailable = items.firstOrNull { ComponentsManager.safeName(it.file) !in installed && it.file !in downloads }
|
||||
items.forEach { item ->
|
||||
// A nightly paired with a stable build rides on that row; Stable/Nightly tabs beside the row pick which one.
|
||||
val pairs = ComponentsManager.nightlyPairs(items)
|
||||
val rows = items.filter { it !in pairs.values }
|
||||
fun shown(row: ComponentsManager.CatalogItem) = pairs[row]?.takeIf { nightlyPicked[row.file] == true } ?: row
|
||||
val firstAvailable = rows.firstOrNull { shown(it).let { s -> ComponentsManager.safeName(s.file) !in installed && s.file !in downloads } }
|
||||
rows.forEach { row ->
|
||||
val item = shown(row)
|
||||
val nightly = pairs[row]
|
||||
val stored = ComponentsManager.safeName(item.file) in installed
|
||||
val progress = downloads[item.file]
|
||||
MenuItem(item.file.removeSuffix(".wcp"), checked = stored,
|
||||
enabled = !stored && progress == null,
|
||||
detail = when {
|
||||
stored -> stringResource(R.string.wincomp_downloaded)
|
||||
progress != null -> if (progress < 0) stringResource(R.string.wincomp_downloading, item.file.removeSuffix(".wcp")) else "$progress%"
|
||||
else -> item.release + " · " + stringResource(R.string.common_size_mb, item.size / 1048576.0)
|
||||
},
|
||||
focusRequester = if (item == firstAvailable) firstItemFocus else null,
|
||||
) {
|
||||
onDownload(item)
|
||||
host.open = null
|
||||
val choose = @Composable { rowModifier: Modifier ->
|
||||
MenuItem(item.file.removeSuffix(".wcp"), checked = stored,
|
||||
enabled = !stored && progress == null,
|
||||
detail = when {
|
||||
stored -> stringResource(R.string.wincomp_downloaded)
|
||||
progress != null -> if (progress < 0) stringResource(R.string.wincomp_downloading, item.file.removeSuffix(".wcp")) else "$progress%"
|
||||
else -> item.release + " · " + stringResource(R.string.common_size_mb, item.size / 1048576.0)
|
||||
},
|
||||
focusRequester = if (row == firstAvailable) firstItemFocus else null,
|
||||
modifier = rowModifier,
|
||||
) {
|
||||
onDownload(item)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
if (nightly == null) choose(Modifier)
|
||||
else Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
choose(Modifier.weight(1f))
|
||||
TabStrip(
|
||||
listOf(stringResource(R.string.comp_channel_stable), stringResource(R.string.comp_channel_nightly)),
|
||||
selected = if (item === nightly) 1 else 0,
|
||||
onSelect = { nightlyPicked[row.file] = it == 1 },
|
||||
bumpers = false, compact = true, modifier = Modifier.padding(start = 6.dp, end = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modif
|
||||
internal fun TabStrip(
|
||||
tabs: List<String>, selected: Int, onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier, focusRequesters: List<FocusRequester>? = null,
|
||||
bumpers: Boolean = true, compact: Boolean = false,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -164,9 +165,9 @@ internal fun TabStrip(
|
||||
val scroll = rememberScrollState()
|
||||
var tight by remember(tabs) { mutableStateOf(false) }
|
||||
LaunchedEffect(scroll.maxValue) { if (scroll.maxValue in 1 until Int.MAX_VALUE) tight = true }
|
||||
val tabPadding = if (tight) 10.dp else 16.dp
|
||||
val tabPadding = if (compact) 10.dp else if (tight) 10.dp else 16.dp
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) {
|
||||
BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
|
||||
if (bumpers) BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
|
||||
Row(
|
||||
modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape)
|
||||
.horizontalScroll(scroll).padding(3.dp),
|
||||
@@ -180,7 +181,7 @@ internal fun TabStrip(
|
||||
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
|
||||
if (on) LaunchedEffect(Unit) { reveal.bringIntoView() }
|
||||
Text(
|
||||
label, fontSize = 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
|
||||
label, fontSize = if (compact) 12.sp else 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.bringIntoViewRequester(reveal)
|
||||
@@ -191,11 +192,11 @@ internal fun TabStrip(
|
||||
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = tabPadding, vertical = 12.dp),
|
||||
.padding(horizontal = tabPadding, vertical = if (compact) 6.dp else 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) }
|
||||
if (bumpers) BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,13 +115,14 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
drop = drop.takeIf { headset },
|
||||
overlay = { lifted ->
|
||||
if (headset) DropSheet(
|
||||
drop, glassesTabs(glasses), covered = lifted,
|
||||
drop, glassesTabs(glasses) + (GlassesTab.CONTROLLERS to stringResource(R.string.vr_controllers_tab)), covered = lifted,
|
||||
button = { enabled ->
|
||||
PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy && glasses.usable, large = true, icon = Icons.Filled.PlayArrow, fill = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("play-steamvr")) { if (enabled) { drop.open = false; a.onPlaySteamVr() } }
|
||||
},
|
||||
) { tab ->
|
||||
GlassesGroups(rememberMenuHost(), glasses, glassesSettings,
|
||||
if (tab == GlassesTab.CONTROLLERS) VrControllerGroups(rememberMenuHost())
|
||||
else GlassesGroups(rememberMenuHost(), glasses, glassesSettings,
|
||||
onRecenter = GlassesMenuMirror::recenter, inSession = false, tab = tab)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,7 +51,6 @@ 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.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
@@ -68,7 +67,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
val narrow = LocalNarrowPane.current
|
||||
val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700
|
||||
val id = key.substringAfter(':')
|
||||
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
|
||||
val item = StoresState.detailItem(store, id)
|
||||
val installed = StoresState.installedGame(store, id)
|
||||
val rawDownload = StoresState.download("${store.id}:$id")
|
||||
val download = rawDownload?.takeIf { it.isActive }
|
||||
@@ -86,18 +85,23 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
.clip(Shape16).background(artBrush(hueOf(title))),
|
||||
) {
|
||||
val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover
|
||||
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
// The card it came from keeps its own bitmap under the hero until the wide art is decoded.
|
||||
val heroArt = cardArtRequest(art, item?.tallImageUrl)
|
||||
if (heroArt != null) AsyncImage(model = heroArt, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent)))
|
||||
if (scores != null && !stacked) {
|
||||
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f))))
|
||||
HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
|
||||
val passName = item?.includedWith.orEmpty()
|
||||
val state = when {
|
||||
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
|
||||
download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location
|
||||
failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location
|
||||
item?.subscription == true -> "PC Game Pass"
|
||||
item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
|
||||
item?.subscription == true -> passName.ifBlank { "PC Game Pass" }
|
||||
passName.isNotBlank() -> "Available with $passName"
|
||||
item?.accessible == true -> stringResource(R.string.stores_eyebrow_library)
|
||||
else -> stringResource(R.string.stores_eyebrow_store)
|
||||
}
|
||||
@@ -153,14 +157,25 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key)
|
||||
}
|
||||
}
|
||||
item?.accessible == true && StoresState.isUnfinished(item) -> {
|
||||
item?.installable == true && StoresState.isUnfinished(item) -> {
|
||||
InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
|
||||
}
|
||||
item?.accessible == true -> InstallButton(
|
||||
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
|
||||
leadOn,
|
||||
) { StoresState.requestInstall(ctx, item) }
|
||||
item?.installable == true -> {
|
||||
InstallButton(
|
||||
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
|
||||
leadOn,
|
||||
) { StoresState.requestInstall(ctx, item) }
|
||||
if (!item.accessible) {
|
||||
SecondaryButton(
|
||||
when {
|
||||
item.isFree -> stringResource(R.string.stores_get_free)
|
||||
item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice)
|
||||
else -> stringResource(R.string.stores_view_on, store.shortLabel)
|
||||
},
|
||||
) { openStoreUrl(ctx, item) }
|
||||
}
|
||||
}
|
||||
item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) }
|
||||
item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true, modifier = leadOn) { openStoreUrl(ctx, item) }
|
||||
}
|
||||
@@ -171,7 +186,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
}
|
||||
if (StoresMotion.heroLands) hero() else Rise(1) { hero() }
|
||||
if (scores != null) Rise(2) {
|
||||
val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id }
|
||||
val listed = (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
|
||||
val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty()
|
||||
val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty()
|
||||
val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty()
|
||||
|
||||
@@ -84,6 +84,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.StoreLanguage
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
@@ -495,6 +496,7 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val st = s.value
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
SettingsRow(stringResource(R.string.stores_tabs_arrange), null) {
|
||||
RoundAction(Icons.Filled.SwapHoriz, stringResource(R.string.stores_tabs_arrange), size = 36.dp) {
|
||||
StoresMotion.fold()
|
||||
@@ -510,8 +512,33 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
|
||||
SettingsRow(stringResource(R.string.stores_show_mature), null) {
|
||||
ToggleSwitch(st.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
|
||||
}
|
||||
var langOpen by remember { mutableStateOf(false) }
|
||||
val storedLanguage = SessionPrefs.storesInstallLanguage(context)
|
||||
var language by remember(storedLanguage, StoreLanguage.preferredCode(context)) { mutableStateOf(storedLanguage) }
|
||||
SettingsRow(stringResource(R.string.stores_install_language), stringResource(R.string.stores_install_language_hint), stacked = true) {
|
||||
Box {
|
||||
ValueChip(
|
||||
if (language.isEmpty()) stringResource(R.string.stores_install_language_system)
|
||||
else StoreLanguage.displayName(language),
|
||||
langOpen,
|
||||
) { langOpen = !langOpen }
|
||||
AnchoredMenu(langOpen, { langOpen = false }, title = stringResource(R.string.stores_install_language)) { first ->
|
||||
val options = listOf("") + StoreLanguage.KNOWN
|
||||
options.forEachIndexed { i, code ->
|
||||
MenuItem(
|
||||
if (code.isEmpty()) stringResource(R.string.stores_install_language_system) else StoreLanguage.displayName(code),
|
||||
checked = language == code,
|
||||
focusRequester = if (i == 0) first else null,
|
||||
) {
|
||||
language = code
|
||||
SessionPrefs.setStoresInstallLanguage(context, code)
|
||||
langOpen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
var gogAchievements by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(com.droiddeck.launcher.stores.gog.GogComet.enabled(context)) }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
if (gogAchievements) com.droiddeck.launcher.core.AddonPack.COMET.ensure(context)
|
||||
|
||||
@@ -25,7 +25,10 @@ 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.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
|
||||
@@ -42,8 +45,12 @@ import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreLanguage
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
import com.droiddeck.launcher.stores.gog.GogDownloadManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
|
||||
@@ -55,7 +62,19 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
|
||||
val remembered = remember { SessionPrefs.storesInstallTarget(ctx) }
|
||||
val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0)
|
||||
val focus = remember { List(targets.size) { FocusRequester() } }
|
||||
val askLanguage = StoreLanguage.selectable(item.store)
|
||||
val preferredLanguage = StoreLanguage.preferredCode(ctx)
|
||||
var languages by remember { mutableStateOf(StoreLanguage.fallbackList(item.store)) }
|
||||
var chosen by remember(preferredLanguage) { mutableStateOf(StoreLanguage.pick(languages, preferredLanguage)) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus[defaultIndex] } }
|
||||
LaunchedEffect(item.key) {
|
||||
if (item.store != com.droiddeck.launcher.stores.Store.GOG) return@LaunchedEffect
|
||||
val listed = withContext(Dispatchers.IO) { GogDownloadManager.listLanguages(ctx, item.id) }
|
||||
if (listed.isEmpty()) return@LaunchedEffect
|
||||
languages = listed
|
||||
chosen = StoreLanguage.pick(listed, chosen)
|
||||
}
|
||||
AppDialog(shown, close, "installWhere", wide = false, maxWidth = if (targets.size > 2) 600.dp else 440.dp) {
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -65,11 +84,27 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.common_cancel), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).controllerConfirm(onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Rise(1) {
|
||||
if (askLanguage) Rise(1) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.stores_install_language), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
Box(Modifier.weight(1f)) {
|
||||
ValueChip(StoreLanguage.displayName(chosen).ifEmpty { chosen }, menuOpen, modifier = Modifier.fillMaxWidth()) { menuOpen = !menuOpen }
|
||||
AnchoredMenu(menuOpen, { menuOpen = false }, title = stringResource(R.string.stores_install_language), compact = true) { first ->
|
||||
languages.forEachIndexed { i, code ->
|
||||
MenuItem(StoreLanguage.displayName(code).ifEmpty { code }, checked = code == chosen, focusRequester = if (i == 0) first else null) {
|
||||
chosen = code
|
||||
menuOpen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rise(if (askLanguage) 2 else 1) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
targets.forEachIndexed { i, t ->
|
||||
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
|
||||
@@ -89,7 +124,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
|
||||
warning = isLow, modifier = Modifier.weight(1f),
|
||||
) {
|
||||
SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath)
|
||||
StoresState.install(ctx, item, t.root)
|
||||
StoresState.install(ctx, item, t.root, if (askLanguage) chosen else null)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreLanguage
|
||||
import com.droiddeck.launcher.stores.epic.EpicClient
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -201,12 +202,12 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val narrow = LocalNarrowPane.current
|
||||
val padH = if (narrow) 14.dp else 22.dp
|
||||
var chip by rememberSaveable { mutableStateOf(Store.GOG.id) }
|
||||
val tabs = remember {
|
||||
androidx.compose.runtime.mutableStateListOf<StoreTab>().apply {
|
||||
addAll(arrangeStoreTabs(SessionPrefs.storesTabOrder(ctx), SessionPrefs.storesTabHidden(ctx)))
|
||||
}
|
||||
}
|
||||
var chip by rememberSaveable { mutableStateOf(tabs.firstOrNull { it.shown }?.store?.id ?: Store.GOG.id) }
|
||||
var arranging by remember { mutableStateOf(false) }
|
||||
val shownStores = tabs.filter { it.shown }.map { it.store }
|
||||
fun commitTabs() {
|
||||
@@ -543,7 +544,10 @@ private fun InstallWhere(item: CatalogItem) {
|
||||
val ctx = LocalContext.current
|
||||
val pal = LocalPalette.current
|
||||
val from = remember(item) { StoresMotion.recentInstall() }
|
||||
if (from == null) { InstallWhereDialog(item) { StoresState.pendingInstall = null }; return }
|
||||
if (from == null || StoreLanguage.selectable(item.store)) {
|
||||
InstallWhereDialog(item) { StoresState.pendingInstall = null }
|
||||
return
|
||||
}
|
||||
val title = stringResource(R.string.stores_install_title, item.title)
|
||||
val internalLabel = stringResource(R.string.stores_target_internal)
|
||||
val label = StoresMotion.installLabel
|
||||
@@ -638,8 +642,9 @@ private fun StoreDot(store: Store, signedIn: Boolean) {
|
||||
Box(
|
||||
Modifier.size(12.dp)
|
||||
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
|
||||
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot),
|
||||
) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) }
|
||||
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot)
|
||||
.background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1006,8 +1011,11 @@ private fun Storefront(
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val focus = LocalFrontFocus.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val headerItems = 1 + (if (sizeSortPending) 1 else 0) +
|
||||
(if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0)
|
||||
// A bar only where the tab has nothing else on it: shelves, the shelf of owned games, or a grid still loading.
|
||||
val gridEmpty = if (showGrid) visible.isEmpty() else (shelves == null || shelves.isEmpty) && library.isEmpty()
|
||||
val barLine = StoresState.status[store]?.takeIf { gridEmpty && syncCount(it) != null }
|
||||
val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (barLine != null) 1 else 0) +
|
||||
(if (StoresState.problems[store] != null) 1 else 0)
|
||||
val listMode = s.storesView == "list" && tab == "library"
|
||||
val capsules = s.storesView == "capsules" && tab == "library"
|
||||
val tileWidth = if (capsules) CapsuleWidth else CardWidth
|
||||
@@ -1090,7 +1098,7 @@ private fun Storefront(
|
||||
Text(stringResource(R.string.store_section_loading), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
StoresState.status[store]?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } }
|
||||
barLine?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } }
|
||||
StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } }
|
||||
when (tab) {
|
||||
"library" -> if (!loaded) Unit
|
||||
@@ -1479,22 +1487,35 @@ private fun GameCard(
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncCount(line: String): Pair<Int, Int>? {
|
||||
val match = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line) ?: return null
|
||||
val done = match.groupValues[1].toIntOrNull() ?: return null
|
||||
val total = match.groupValues[2].toIntOrNull()?.takeIf { it > 0 } ?: return null
|
||||
return done to total
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncBar(line: String) {
|
||||
val count = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line)
|
||||
val done = count?.groupValues?.get(1)?.toIntOrNull()
|
||||
val total = count?.groupValues?.get(2)?.toIntOrNull()?.takeIf { it > 0 }
|
||||
val count = syncCount(line) ?: return
|
||||
val done = count.first
|
||||
val total = count.second
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
if (done != null && total != null) androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp))
|
||||
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(3.dp))
|
||||
}
|
||||
if (done != null && total != null) Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
Box(Modifier.weight(1f)) { androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp)) }
|
||||
Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private val CardStrip = 26.dp
|
||||
|
||||
@Composable
|
||||
internal fun cardArtRequest(url: String?, cover: String?): coil.request.ImageRequest? {
|
||||
val ctx = LocalContext.current
|
||||
val under = cover?.takeIf { it != url }
|
||||
return remember(url, under) {
|
||||
url?.let { coil.request.ImageRequest.Builder(ctx).data(it).apply { if (under != null) placeholderMemoryCacheKey(under) }.build() }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CardFace(
|
||||
@@ -1502,26 +1523,27 @@ private fun CardFace(
|
||||
marker: @Composable () -> Unit,
|
||||
) {
|
||||
val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl
|
||||
val art = cardArtRequest(url, item.tallImageUrl)
|
||||
val blurs = android.os.Build.VERSION.SDK_INT >= 31
|
||||
var landscape by remember(url) { mutableStateOf(false) }
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip
|
||||
Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
|
||||
if (url != null && capsule && landscape) {
|
||||
if (art != null && capsule && landscape) {
|
||||
// Stores without portrait covers (itch) would lose most of the art to a portrait crop.
|
||||
AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp))
|
||||
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp))
|
||||
Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f)))
|
||||
AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip))
|
||||
} else if (url != null) AsyncImage(
|
||||
model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(),
|
||||
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip))
|
||||
} else if (art != null) AsyncImage(
|
||||
model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(),
|
||||
onSuccess = { r ->
|
||||
val art = r.result.drawable
|
||||
if (capsule && art.intrinsicWidth > 0 && art.intrinsicHeight > 0) landscape = art.intrinsicWidth > art.intrinsicHeight * 0.95f
|
||||
val drawn = r.result.drawable
|
||||
if (capsule && drawn.intrinsicWidth > 0 && drawn.intrinsicHeight > 0) landscape = drawn.intrinsicWidth > drawn.intrinsicHeight * 0.95f
|
||||
},
|
||||
)
|
||||
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) {
|
||||
if (url != null) AsyncImage(
|
||||
model = url, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
if (art != null) AsyncImage(
|
||||
model = art, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().wrapContentHeight(Alignment.Bottom, unbounded = true).height(total).blur(24.dp),
|
||||
)
|
||||
Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.45f else 0.7f)))
|
||||
@@ -1574,9 +1596,10 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi
|
||||
@Composable
|
||||
internal fun CardArt(item: CatalogItem, modifier: Modifier) {
|
||||
val url = item.imageUrl ?: item.tallImageUrl
|
||||
val art = cardArtRequest(url, item.tallImageUrl)
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Box(modifier.background(colors.surfaceVariant).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
|
||||
if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
else Text(item.title, fontSize = 10.sp, fontWeight = FontWeight.Black, color = Color.White, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(6.dp))
|
||||
Spacer(Modifier.matchParentSize().background(androidx.compose.ui.graphics.Brush.verticalGradient(0.6f to Color.Transparent, 1f to colors.surface.copy(alpha = 0.85f))))
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
val actions = mouse + ACTION_SHOW_KEYBOARD + GestureKeys.codes.keys.map { "key_$it" } + ACTION_KEY_TILDE
|
||||
val p = LocalPalette.current
|
||||
|
||||
GestureDialogFrame(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_editor_subtitle), onDismiss,
|
||||
GestureDialogFrame(onDismiss,
|
||||
footer = {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) }
|
||||
@@ -83,15 +83,6 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}, modifier = Modifier.testTag("gesture-save")) { Text(stringResource(R.string.gesture_save_changes)) }
|
||||
}
|
||||
}) {
|
||||
Column(Modifier.padding(horizontal = 20.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default"))
|
||||
if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game"))
|
||||
if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) }
|
||||
}
|
||||
Text(stringResource(if (!forGame) R.string.gesture_scope_default else if (gameDraft == null) R.string.gesture_inheriting else R.string.gesture_scope_game),
|
||||
color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 12.dp))
|
||||
}
|
||||
ScrollableTabRow(tab, edgePadding = 12.dp, containerColor = p.surface, divider = { HorizontalDivider(color = p.line) }) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(tab == index, onClick = { tab = index }, text = { Text(stringResource(title)) }, selectedContentColor = p.onBackground,
|
||||
@@ -100,9 +91,16 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
key(tab) {
|
||||
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default"))
|
||||
if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game"))
|
||||
}
|
||||
if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) }
|
||||
}
|
||||
when (tab) {
|
||||
0 -> {
|
||||
GestureSection(R.string.gesture_tab_one, R.string.gesture_one_hint)
|
||||
GestureSection(R.string.gesture_tab_one)
|
||||
GestureCard(R.string.gesture_tap, config.tapEnabled, { change(config.copy(tapEnabled = it)) }) {
|
||||
GestureActionField("tap", R.string.gesture_tap, config.tapAction, actions) { change(config.copy(tapAction = it)) }
|
||||
}
|
||||
@@ -118,7 +116,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
GestureSection(R.string.gesture_tab_two, R.string.gesture_two_hint)
|
||||
GestureSection(R.string.gesture_tab_two)
|
||||
GestureCard(R.string.gesture_two_tap, config.twoFingerTapEnabled, { change(config.copy(twoFingerTapEnabled = it)) }) {
|
||||
GestureActionField("two-tap", R.string.gesture_two_tap, config.twoFingerTapAction, actions) { change(config.copy(twoFingerTapAction = it)) }
|
||||
}
|
||||
@@ -134,7 +132,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
GestureSection(R.string.gesture_tab_three, R.string.gesture_three_hint)
|
||||
GestureSection(R.string.gesture_tab_three)
|
||||
GestureCard(R.string.gesture_three_tap, config.threeFingerTapEnabled, { change(config.copy(threeFingerTapEnabled = it)) }) {
|
||||
GestureActionField("three-tap", R.string.gesture_three_tap, config.threeFingerTapAction, actions) { change(config.copy(threeFingerTapAction = it)) }
|
||||
}
|
||||
@@ -147,16 +145,13 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
GestureSection(R.string.gesture_tab_options, R.string.gesture_options_hint)
|
||||
GestureSection(R.string.gesture_tab_options)
|
||||
GestureSettingsCard(R.string.gesture_movement) {
|
||||
Text(stringResource(R.string.gesture_movement_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
|
||||
GestureChoiceChips(config.mouseDragMovementMode, listOf(MOUSE_DRAG_MOVEMENT_DIRECT to stringResource(R.string.gesture_direct), MOUSE_DRAG_MOVEMENT_RELATIVE to stringResource(R.string.gesture_relative))) { change(config.copy(mouseDragMovementMode = it)) }
|
||||
Text(stringResource(R.string.gesture_middle_relative_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
GestureSettingsCard(R.string.gesture_recognition) {
|
||||
Text(stringResource(R.string.gesture_threshold, config.gestureThreshold), style = MaterialTheme.typography.bodyMedium)
|
||||
Slider(config.gestureThreshold.toFloat(), { change(config.copy(gestureThreshold = it.toInt())) }, valueRange = 10f..100f, steps = 17)
|
||||
Text(stringResource(R.string.gesture_threshold_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
GestureSettingsCard(R.string.gesture_feedback) {
|
||||
GestureToggle(R.string.gesture_debug, config.showGestureDebugOverlay) { change(config.copy(showGestureDebugOverlay = it)) }
|
||||
@@ -170,7 +165,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () -> Unit,
|
||||
private fun GestureDialogFrame(onDismiss: () -> Unit,
|
||||
footer: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
|
||||
val p = LocalPalette.current
|
||||
// The app window does not opt into RTL, so these dialogs follow their resource locale explicitly.
|
||||
@@ -181,15 +176,8 @@ private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () ->
|
||||
Surface(Modifier.widthIn(max = 1040.dp).fillMaxWidth().fillMaxHeight(), shape = RoundedCornerShape(22.dp), color = p.background,
|
||||
contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = p.primary.copy(alpha = 0.14f)) {
|
||||
Icon(Icons.Default.TouchApp, null, tint = p.primary, modifier = Modifier.padding(10.dp).size(24.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = p.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) }
|
||||
Row(Modifier.fillMaxWidth().padding(end = 4.dp), horizontalArrangement = Arrangement.End) {
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) }
|
||||
}
|
||||
content()
|
||||
HorizontalDivider(color = p.line)
|
||||
@@ -202,11 +190,8 @@ private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () ->
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GestureSection(@StringRes title: Int, @StringRes hint: Int) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(hint), style = MaterialTheme.typography.bodyMedium, color = LocalPalette.current.onSurfaceVariant)
|
||||
}
|
||||
private fun GestureSection(@StringRes title: Int) {
|
||||
Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -242,18 +227,18 @@ private fun GestureActionField(id: String, @StringRes gesture: Int, selected: St
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(19.dp), tint = p.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
if (open) GestureActionPicker(choices, selected, stringResource(gesture), onDismiss = { open = false }) { open = false; onChoose(it) }
|
||||
if (open) GestureActionPicker(choices, selected, onDismiss = { open = false }) { open = false; onChoose(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GestureActionPicker(choices: List<String>, selected: String, gesture: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
|
||||
internal fun GestureActionPicker(choices: List<String>, selected: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var category by remember { mutableStateOf<GestureActionGroup?>(null) }
|
||||
val catalog = choices.distinct().map { GestureAction(it, gestureActionTitle(it), gestureActionGroup(it)) }
|
||||
val groups = GestureActionGroup.entries.filter { group -> catalog.any { it.group == group } }
|
||||
val shown = filterGestureActions(catalog, query, category)
|
||||
val p = LocalPalette.current
|
||||
GestureDialogFrame(stringResource(R.string.gesture_action), stringResource(R.string.gesture_action_for, gesture), onDismiss) {
|
||||
GestureDialogFrame(onDismiss) {
|
||||
OutlinedTextField(query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).testTag("gesture-action-search"),
|
||||
singleLine = true, shape = RoundedCornerShape(12.dp), placeholder = { Text(stringResource(R.string.gesture_search_actions)) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = {
|
||||
|
||||
@@ -57,7 +57,8 @@ object SelfInstaller {
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val flags = if (Build.VERSION.SDK_INT >= 28) {
|
||||
PackageManager.GET_SIGNING_CERTIFICATES
|
||||
// Android 9 and 10 only collect an archive's certificates when GET_SIGNATURES is set.
|
||||
PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES
|
||||
} else {
|
||||
PackageManager.GET_SIGNATURES
|
||||
}
|
||||
@@ -75,15 +76,18 @@ object SelfInstaller {
|
||||
throw IOException(context.getString(R.string.selfinst_downgrade, code, BuildConfig.VERSION_CODE))
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
val signing = info.signingInfo ?: throw IOException(context.getString(R.string.selfinst_no_signing))
|
||||
val digests = signing.apkContentsSigners.orEmpty().map { cert ->
|
||||
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) {
|
||||
throw IOException(context.getString(R.string.selfinst_wrong_signer))
|
||||
}
|
||||
val digests = archiveSignerDigests(info)
|
||||
if (digests.isEmpty()) throw IOException(context.getString(R.string.selfinst_no_signing))
|
||||
if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) {
|
||||
throw IOException(context.getString(R.string.selfinst_wrong_signer))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun archiveSignerDigests(info: android.content.pm.PackageInfo): List<String> {
|
||||
val certs = (if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else null) ?: info.signatures
|
||||
return certs.orEmpty().map { cert ->
|
||||
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">النقر مرة واحدة</string>
|
||||
<string name="gesture_hold_delay">مهلة الضغط المستمر</string>
|
||||
<string name="gesture_double_delay">الفاصل بين النقرتين</string>
|
||||
<string name="gesture_scope_game">ينطبق على اللعبة الحالية</string>
|
||||
<string name="gesture_scope_default">الإعدادات الافتراضية لجميع الألعاب</string>
|
||||
<string name="gesture_reset">استعادة إعدادات الإيماءات الأصلية</string>
|
||||
<string name="gesture_cancel">إلغاء</string>
|
||||
<string name="gesture_action">اختيار إجراء</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">عجلة الفأرة</string>
|
||||
<string name="gesture_threshold">عتبة الإيماءات: %1$d بكسل</string>
|
||||
<string name="gesture_debug">إظهار الإيماءة المكتشفة</string>
|
||||
<string name="gesture_editor_subtitle">عناصر تحكم الفأرة ولوحة المفاتيح مرتبة حسب الإيماءة</string>
|
||||
<string name="gesture_tab_one">إصبع واحد</string>
|
||||
<string name="gesture_tab_two">إصبعان</string>
|
||||
<string name="gesture_tab_three">ثلاثة أصابع</string>
|
||||
<string name="gesture_tab_options">خيارات</string>
|
||||
<string name="gesture_one_hint">اختر إجراء النقر أو السحب أو الضغط المستمر.</string>
|
||||
<string name="gesture_two_hint">اضبط تحريك الكاميرا والتكبير والنقرات الثانوية.</string>
|
||||
<string name="gesture_three_hint">اجعل الاختصارات الإضافية في متناولك.</string>
|
||||
<string name="gesture_options_hint">اضبط الحركة والتعرّف والاستجابة المرئية.</string>
|
||||
<string name="gesture_defaults">الافتراضية</string>
|
||||
<string name="gesture_game">هذه اللعبة</string>
|
||||
<string name="gesture_use_defaults">استخدام الإعدادات الافتراضية</string>
|
||||
<string name="gesture_inheriting">تُستخدم الإعدادات الافتراضية. يؤدي التعديل إلى إنشاء إعدادات خاصة باللعبة.</string>
|
||||
<string name="gesture_save_changes">حفظ التغييرات</string>
|
||||
<string name="gesture_close">إغلاق</string>
|
||||
<string name="gesture_action_label">الإجراء</string>
|
||||
<string name="gesture_action_for">لـ %1$s</string>
|
||||
<string name="gesture_search_actions">البحث عن إجراءات الفأرة أو مفاتيح لوحة المفاتيح</string>
|
||||
<string name="gesture_clear_search">مسح البحث</string>
|
||||
<string name="gesture_all_actions">الكل</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">لوحة الأرقام</string>
|
||||
<string name="gesture_group_symbols">الرموز</string>
|
||||
<string name="gesture_movement">حركة الفأرة</string>
|
||||
<string name="gesture_movement_hint">يتبع السحب المباشر موضع اللمس. ويتبع السحب النسبي حركة الإصبع، وهو مفيد عندما تعيد اللعبة الفأرة إلى المنتصف.</string>
|
||||
<string name="gesture_middle_relative_hint">يتبع تحريك العرض بالزر الأوسط حركة الإصبع دائمًا لتجنّب قفزات المؤشر.</string>
|
||||
<string name="gesture_direct">مباشر</string>
|
||||
<string name="gesture_relative">نسبي</string>
|
||||
<string name="gesture_recognition">التعرّف على الإيماءات</string>
|
||||
<string name="gesture_threshold_hint">تحرّك هذه المسافة قبل أن تتحول إيماءة بإصبعين أو ثلاثة إلى تحريك العرض أو التكبير.</string>
|
||||
<string name="gesture_feedback">الاستجابة المرئية</string>
|
||||
<string name="gesture_mouse_behavior">سلوك زر الفأرة</string>
|
||||
<string name="gesture_ms">%1$d مللي ثانية</string>
|
||||
|
||||
@@ -512,7 +512,9 @@
|
||||
<string name="comp_delete_original_title">هل تريد حذف هذه النسخة الأصلية؟</string>
|
||||
<string name="comp_delete_original_body">هذه النسخة الأصلية من %1$s تعود إلى إصدار سابق من %2$s. يتم دائمًا الاحتفاظ بالنسخة الأصلية للإصدار الحالي.</string>
|
||||
<string name="comp_nightlies_count">في الإصدارات الليلية · %1$d</string>
|
||||
<string name="comp_refresh_to_list">حدّث لعرض الحزم في الإصدارات الليلية.</string>
|
||||
<string name="comp_channel_stable">مستقر</string>
|
||||
<string name="comp_channel_nightly">ليلي</string>
|
||||
<string name="comp_refresh_to_list">حدّث لعرض الحزم في إصدارات Nightlies وDroidDeck.</string>
|
||||
<string name="comp_nothing_new">لا جديد منذ آخر فحص.</string>
|
||||
<string name="comp_import_wcp">استيراد ملف .wcp</string>
|
||||
<string name="comp_restore_original">استعادة النسخة الأصلية لـ Proton هذا</string>
|
||||
@@ -590,6 +592,16 @@
|
||||
<string name="drawer_prev_page">الصفحة السابقة</string>
|
||||
<string name="drawer_next_page">الصفحة التالية</string>
|
||||
<string name="drawer_hud">واجهة الأداء</string>
|
||||
<string name="drawer_hud_fps">معدل الإطارات</string>
|
||||
<string name="drawer_hud_battery">درجة حرارة البطارية</string>
|
||||
<string name="drawer_hud_graph">مخطط الأداء</string>
|
||||
<string name="drawer_hud_color">اللون</string>
|
||||
<string name="drawer_hud_color_mint">نعناعي</string>
|
||||
<string name="drawer_hud_color_white">أبيض</string>
|
||||
<string name="drawer_hud_color_sky">سماوي</string>
|
||||
<string name="drawer_hud_color_amber">كهرماني</string>
|
||||
<string name="drawer_hud_color_violet">بنفسجي</string>
|
||||
<string name="drawer_hud_opacity">العتامة</string>
|
||||
<string name="drawer_scaling">وضع التحجيم</string>
|
||||
<string name="drawer_scaling_sharpness">حدة التحجيم</string>
|
||||
<string name="drawer_effects">مؤثرات الشاشة</string>
|
||||
@@ -1854,7 +1866,8 @@
|
||||
<string name="cmgr_original_kept">تم الاحتفاظ بأصل النسخة المثبَّتة: فهو طريق العودة.</string>
|
||||
<string name="cmgr_not_stored">غير مخزَّن</string>
|
||||
<string name="cmgr_deleted_original">تم حذف أصل %2$s من %1$s.</string>
|
||||
<string name="cmgr_nightlies_only">التنزيلات تأتي فقط من إصدارات Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">التنزيلات تأتي فقط من إصدارات Nightlies وDroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">فشل التنزيل: ربما استُبدل هذا الإصدار الليلي بإصدار أحدث. حدّث القائمة وحاول مرة أخرى.</string>
|
||||
<string name="cmgr_list_predates_checksums">قائمة الحزم هذه أقدم من إضافة المجاميع الاختبارية - حدِّثها وحاوِل مرة أخرى</string>
|
||||
<string name="cmgr_checksum_mismatch">عدم تطابق المجموع الاختباري</string>
|
||||
|
||||
@@ -2110,6 +2123,9 @@
|
||||
<string name="cloud_last_sync">آخر مزامنة %1$s</string>
|
||||
<string name="cloud_never">لم تتم المزامنة بعد</string>
|
||||
<string name="cloud_done">%1$d ملفات</string>
|
||||
<string name="cloud_up_to_date">محدَّث بالفعل</string>
|
||||
<string name="cloud_signed_out">سجّل الدخول إلى المتجر لمزامنة الحفظ</string>
|
||||
<string name="cloud_not_synced">لم تتم مزامنة الحفظ</string>
|
||||
<string name="stores_eyebrow_installed">مثبَّتة</string>
|
||||
<string name="stores_eyebrow_library">في مكتبتك</string>
|
||||
<string name="stores_eyebrow_store">متجر</string>
|
||||
@@ -2421,7 +2437,7 @@
|
||||
<string name="touch_done">تم</string>
|
||||
<string name="touch_save_failed">تعذّر حفظ تخطيط اللمس</string>
|
||||
<string name="touch_saved">تم حفظ تخطيط اللمس في Steam</string>
|
||||
<string name="touch_app_id">التطبيق %1$d</string>
|
||||
<string name="touch_app_id">التطبيق %1$s</string>
|
||||
<string name="touch_label_hint">التسمية (اختياري)</string>
|
||||
<string name="touch_icons_missing">لم يتم العثور على أيقونات Steam</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2520,6 +2536,15 @@
|
||||
<string name="glasses_vr_fov">مجال رؤية SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">القيمة الأقل تقرّب SteamVR وتملأ النظارة؛ والأعلى تُظهر المزيد مما حولك.</string>
|
||||
<string name="glasses_playing">جارٍ اللعب على %1$s</string>
|
||||
<string name="glasses_phone_recenter">إعادة التوسيط</string>
|
||||
<string name="glasses_phone_steamvr_menu">قائمة SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">العصي: اليدان ⇄ اللعبة</string>
|
||||
<string name="glasses_phone_controllers">وحدات التحكم: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">لوحة المفاتيح</string>
|
||||
<string name="glasses_phone_session_menu">قائمة الجلسة</string>
|
||||
<string name="glasses_phone_stop_steamvr">إيقاف SteamVR</string>
|
||||
<string name="glasses_phone_brightness">السطوع %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">البطارية %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">معايرة التتبّع</string>
|
||||
<string name="glasses_calibration_hint">يقلّل الانجراف ويضبط المرجع الأفقي.</string>
|
||||
<string name="glasses_calibration_saved_hint">تُطبَّق المعايرة المحفوظة على هذه النظارة.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Hacer clic una vez</string>
|
||||
<string name="gesture_hold_delay">Tiempo de pulsación sostenida</string>
|
||||
<string name="gesture_double_delay">Intervalo de doble toque</string>
|
||||
<string name="gesture_scope_game">Se aplica al juego actual</string>
|
||||
<string name="gesture_scope_default">Ajustes predeterminados para todos los juegos</string>
|
||||
<string name="gesture_reset">Restablecer los ajustes originales de gestos</string>
|
||||
<string name="gesture_cancel">Cancelar</string>
|
||||
<string name="gesture_action">Elegir acción</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Rueda del ratón</string>
|
||||
<string name="gesture_threshold">Umbral de gestos: %1$d px</string>
|
||||
<string name="gesture_debug">Mostrar gesto reconocido</string>
|
||||
<string name="gesture_editor_subtitle">Controles de ratón y teclado organizados por gesto</string>
|
||||
<string name="gesture_tab_one">Un dedo</string>
|
||||
<string name="gesture_tab_two">Dos dedos</string>
|
||||
<string name="gesture_tab_three">Tres dedos</string>
|
||||
<string name="gesture_tab_options">Opciones</string>
|
||||
<string name="gesture_one_hint">Elige qué debe hacer un toque, arrastre o pulsación sostenida.</string>
|
||||
<string name="gesture_two_hint">Configura el movimiento de cámara, el zoom y los clics secundarios.</string>
|
||||
<string name="gesture_three_hint">Ten más accesos directos al alcance de la mano.</string>
|
||||
<string name="gesture_options_hint">Ajusta el movimiento, el reconocimiento y la respuesta visual.</string>
|
||||
<string name="gesture_defaults">Predeterminados</string>
|
||||
<string name="gesture_game">Este juego</string>
|
||||
<string name="gesture_use_defaults">Usar valores predeterminados</string>
|
||||
<string name="gesture_inheriting">Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego.</string>
|
||||
<string name="gesture_save_changes">Guardar cambios</string>
|
||||
<string name="gesture_close">Cerrar</string>
|
||||
<string name="gesture_action_label">ACCIÓN</string>
|
||||
<string name="gesture_action_for">Para %1$s</string>
|
||||
<string name="gesture_search_actions">Buscar acciones de ratón o teclas del teclado</string>
|
||||
<string name="gesture_clear_search">Borrar búsqueda</string>
|
||||
<string name="gesture_all_actions">Todas</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Teclado numérico</string>
|
||||
<string name="gesture_group_symbols">Símbolos</string>
|
||||
<string name="gesture_movement">Movimiento del ratón</string>
|
||||
<string name="gesture_movement_hint">El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón.</string>
|
||||
<string name="gesture_middle_relative_hint">El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor.</string>
|
||||
<string name="gesture_direct">Directo</string>
|
||||
<string name="gesture_relative">Relativo</string>
|
||||
<string name="gesture_recognition">Reconocimiento de gestos</string>
|
||||
<string name="gesture_threshold_hint">Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom.</string>
|
||||
<string name="gesture_feedback">Respuesta visual</string>
|
||||
<string name="gesture_mouse_behavior">Comportamiento del botón del ratón</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -499,7 +499,9 @@
|
||||
<string name="comp_delete_original_title">¿Eliminar este original?</string>
|
||||
<string name="comp_delete_original_body">Este original de %1$s es de una versión anterior de %2$s. El original de la versión actual siempre se conserva.</string>
|
||||
<string name="comp_nightlies_count">En las Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de las Nightlies.</string>
|
||||
<string name="comp_channel_stable">Estable</string>
|
||||
<string name="comp_channel_nightly">Nocturna</string>
|
||||
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de Nightlies y DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Nada nuevo desde la última comprobación.</string>
|
||||
<string name="comp_import_wcp">Importar .wcp</string>
|
||||
<string name="comp_restore_original">Restaurar el original de este Proton</string>
|
||||
@@ -577,6 +579,16 @@
|
||||
<string name="drawer_prev_page">Página anterior</string>
|
||||
<string name="drawer_next_page">Página siguiente</string>
|
||||
<string name="drawer_hud">HUD de rendimiento</string>
|
||||
<string name="drawer_hud_fps">Tasa de fotogramas</string>
|
||||
<string name="drawer_hud_battery">Temperatura de la batería</string>
|
||||
<string name="drawer_hud_graph">Gráfico de rendimiento</string>
|
||||
<string name="drawer_hud_color">Color</string>
|
||||
<string name="drawer_hud_color_mint">Menta</string>
|
||||
<string name="drawer_hud_color_white">Blanco</string>
|
||||
<string name="drawer_hud_color_sky">Celeste</string>
|
||||
<string name="drawer_hud_color_amber">Ámbar</string>
|
||||
<string name="drawer_hud_color_violet">Violeta</string>
|
||||
<string name="drawer_hud_opacity">Opacidad</string>
|
||||
<string name="drawer_scaling">Modo de escalado</string>
|
||||
<string name="drawer_scaling_sharpness">Nitidez del escalado</string>
|
||||
<string name="drawer_effects">Efectos de pantalla</string>
|
||||
@@ -1830,7 +1842,8 @@
|
||||
<string name="cmgr_original_kept">El original de la versión instalada se conserva: es el camino de vuelta.</string>
|
||||
<string name="cmgr_not_stored">No guardado</string>
|
||||
<string name="cmgr_deleted_original">Se eliminó el original de %1$s de %2$s.</string>
|
||||
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones de Nightlies y DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Falló la descarga: es posible que esta compilación nocturna se haya reemplazado por una más nueva. Actualiza la lista e inténtalo de nuevo.</string>
|
||||
<string name="cmgr_list_predates_checksums">Esta lista de paquetes es anterior a las sumas de verificación - actualízala e inténtalo de nuevo</string>
|
||||
<string name="cmgr_checksum_mismatch">La suma de verificación no coincide</string>
|
||||
<string name="sprefs_upscaler_linear">Lineal</string>
|
||||
@@ -2053,6 +2066,9 @@
|
||||
<string name="cloud_last_sync">Última sincronización %1$s</string>
|
||||
<string name="cloud_never">Aún sin sincronizar</string>
|
||||
<string name="cloud_done">%1$d archivos</string>
|
||||
<string name="cloud_up_to_date">Ya está al día</string>
|
||||
<string name="cloud_signed_out">Inicia sesión en la tienda para sincronizar las partidas</string>
|
||||
<string name="cloud_not_synced">No se sincronizaron las partidas</string>
|
||||
<string name="stores_eyebrow_installed">instalado</string>
|
||||
<string name="stores_eyebrow_library">en tu biblioteca</string>
|
||||
<string name="stores_eyebrow_store">tienda</string>
|
||||
@@ -2458,6 +2474,15 @@
|
||||
<string name="glasses_vr_fov">Campo de visión de SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor.</string>
|
||||
<string name="glasses_playing">Jugando en %1$s</string>
|
||||
<string name="glasses_phone_recenter">Recentrar</string>
|
||||
<string name="glasses_phone_steamvr_menu">Menú de SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Sticks: manos ⇄ juego</string>
|
||||
<string name="glasses_phone_controllers">Controles: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Teclado</string>
|
||||
<string name="glasses_phone_session_menu">Menú de sesión</string>
|
||||
<string name="glasses_phone_stop_steamvr">Detener SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Brillo %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Batería %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Calibración del seguimiento</string>
|
||||
<string name="glasses_calibration_hint">Reduce la deriva y alinea la referencia horizontal.</string>
|
||||
<string name="glasses_calibration_saved_hint">Estas gafas usan la calibración guardada.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Еднократно щракване</string>
|
||||
<string name="gesture_hold_delay">Закъснение за задържане</string>
|
||||
<string name="gesture_double_delay">Интервал за двойно докосване</string>
|
||||
<string name="gesture_scope_game">Прилага се към текущата игра</string>
|
||||
<string name="gesture_scope_default">Настройки по подразбиране за всички игри</string>
|
||||
<string name="gesture_reset">Възстановяване на първоначалните настройки на жестовете</string>
|
||||
<string name="gesture_cancel">Отказ</string>
|
||||
<string name="gesture_action">Избор на действие</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Колелце на мишката</string>
|
||||
<string name="gesture_threshold">Праг на жестовете: %1$d пиксела</string>
|
||||
<string name="gesture_debug">Показване на разпознатия жест</string>
|
||||
<string name="gesture_editor_subtitle">Управление с мишка и клавиатура, подредено по жестове</string>
|
||||
<string name="gesture_tab_one">Един пръст</string>
|
||||
<string name="gesture_tab_two">Два пръста</string>
|
||||
<string name="gesture_tab_three">Три пръста</string>
|
||||
<string name="gesture_tab_options">Опции</string>
|
||||
<string name="gesture_one_hint">Изберете действието при докосване, плъзгане или задържане.</string>
|
||||
<string name="gesture_two_hint">Настройте преместването на камерата, мащабирането и вторичните щраквания.</string>
|
||||
<string name="gesture_three_hint">Дръжте допълнителните команди подръка.</string>
|
||||
<string name="gesture_options_hint">Настройте движението, разпознаването и визуалната обратна връзка.</string>
|
||||
<string name="gesture_defaults">Стандартни настройки</string>
|
||||
<string name="gesture_game">Тази игра</string>
|
||||
<string name="gesture_use_defaults">Използване на подразбиращите се</string>
|
||||
<string name="gesture_inheriting">Използват се настройките по подразбиране. Редактирането създава настройки за тази игра.</string>
|
||||
<string name="gesture_save_changes">Запазване на промените</string>
|
||||
<string name="gesture_close">Затвори</string>
|
||||
<string name="gesture_action_label">ДЕЙСТВИЕ</string>
|
||||
<string name="gesture_action_for">За %1$s</string>
|
||||
<string name="gesture_search_actions">Търсене на действия с мишката или клавиши</string>
|
||||
<string name="gesture_clear_search">Изчистване на търсенето</string>
|
||||
<string name="gesture_all_actions">Всички</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Цифрова клавиатура</string>
|
||||
<string name="gesture_group_symbols">Символи</string>
|
||||
<string name="gesture_movement">Движение на мишката</string>
|
||||
<string name="gesture_movement_hint">Директното плъзгане следва мястото на докосване. Относителното следва движението на пръста и е полезно, когато играта центрира мишката.</string>
|
||||
<string name="gesture_middle_relative_hint">Преместването със средния бутон винаги следва движението на пръста, за да няма скокове на курсора.</string>
|
||||
<string name="gesture_direct">Директно</string>
|
||||
<string name="gesture_relative">Относително</string>
|
||||
<string name="gesture_recognition">Разпознаване на жестове</string>
|
||||
<string name="gesture_threshold_hint">Изминете това разстояние, преди жест с два или три пръста да стане преместване или мащабиране.</string>
|
||||
<string name="gesture_feedback">Визуална обратна връзка</string>
|
||||
<string name="gesture_mouse_behavior">Поведение на бутона на мишката</string>
|
||||
<string name="gesture_ms">%1$d мс</string>
|
||||
|
||||
@@ -504,7 +504,9 @@
|
||||
<string name="comp_delete_original_title">Изтриване на този оригинал?</string>
|
||||
<string name="comp_delete_original_body">Този оригинал на %1$s е от по-стара версия на %2$s. Оригиналът на текущата версия винаги се запазва.</string>
|
||||
<string name="comp_nightlies_count">В Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Обновете, за да видите пакетите в Nightlies.</string>
|
||||
<string name="comp_channel_stable">Стабилна</string>
|
||||
<string name="comp_channel_nightly">Нощна</string>
|
||||
<string name="comp_refresh_to_list">Обновете, за да видите пакетите в Nightlies и DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Нищо ново от последната проверка.</string>
|
||||
<string name="comp_import_wcp">Импортиране на .wcp</string>
|
||||
<string name="comp_restore_original">Възстановяване на оригинала на този Proton</string>
|
||||
@@ -582,6 +584,16 @@
|
||||
<string name="drawer_prev_page">Предишна страница</string>
|
||||
<string name="drawer_next_page">Следваща страница</string>
|
||||
<string name="drawer_hud">HUD за производителност</string>
|
||||
<string name="drawer_hud_fps">Честота на кадрите</string>
|
||||
<string name="drawer_hud_battery">Температура на батерията</string>
|
||||
<string name="drawer_hud_graph">Графика на производителността</string>
|
||||
<string name="drawer_hud_color">Цвят</string>
|
||||
<string name="drawer_hud_color_mint">Мента</string>
|
||||
<string name="drawer_hud_color_white">Бяло</string>
|
||||
<string name="drawer_hud_color_sky">Небесно</string>
|
||||
<string name="drawer_hud_color_amber">Кехлибарено</string>
|
||||
<string name="drawer_hud_color_violet">Виолетово</string>
|
||||
<string name="drawer_hud_opacity">Прозрачност</string>
|
||||
<string name="drawer_scaling">Режим на мащабиране</string>
|
||||
<string name="drawer_scaling_sharpness">Острота на мащабиране</string>
|
||||
<string name="drawer_effects">Ефекти на екрана</string>
|
||||
@@ -1770,7 +1782,8 @@
|
||||
<string name="cmgr_original_kept">Оригиналът на инсталираната версия е запазен: той е пътят назад.</string>
|
||||
<string name="cmgr_not_stored">Не е запазен</string>
|
||||
<string name="cmgr_deleted_original">Изтрит е оригиналът %1$s на %2$s.</string>
|
||||
<string name="cmgr_nightlies_only">Изтеглянията идват само от версиите Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">Изтеглянията идват само от версиите на Nightlies и DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Изтеглянето не бе успешно: тази нощна версия може да е заменена с по-нова. Обновете списъка и опитайте отново.</string>
|
||||
<string name="cmgr_list_predates_checksums">Този списък с пакети е отпреди въвеждането на контролни суми - обнови го и опитай отново</string>
|
||||
<string name="cmgr_checksum_mismatch">Несъответствие в контролната сума</string>
|
||||
|
||||
@@ -2026,6 +2039,9 @@
|
||||
<string name="cloud_last_sync">Последна синхронизация %1$s</string>
|
||||
<string name="cloud_never">Още не е синхронизирано</string>
|
||||
<string name="cloud_done">%1$d файла</string>
|
||||
<string name="cloud_up_to_date">Вече е актуално</string>
|
||||
<string name="cloud_signed_out">Влезте в магазина, за да синхронизирате записите</string>
|
||||
<string name="cloud_not_synced">Записите не бяха синхронизирани</string>
|
||||
<string name="stores_eyebrow_installed">инсталирана</string>
|
||||
<string name="stores_eyebrow_library">в библиотеката ти</string>
|
||||
<string name="stores_eyebrow_store">магазин</string>
|
||||
@@ -2329,7 +2345,7 @@
|
||||
<string name="touch_done">Готово</string>
|
||||
<string name="touch_save_failed">Сензорното разположение не можа да се запази</string>
|
||||
<string name="touch_saved">Сензорното разположение е запазено в Steam</string>
|
||||
<string name="touch_app_id">Приложение %1$d</string>
|
||||
<string name="touch_app_id">Приложение %1$s</string>
|
||||
<string name="touch_label_hint">Етикет (незадължително)</string>
|
||||
<string name="touch_icons_missing">Иконите на Steam не бяха намерени</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2420,6 +2436,15 @@
|
||||
<string name="glasses_vr_fov">Зрително поле на SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">По-ниско приближава SteamVR и запълва очилата; по-високо показва повече около вас.</string>
|
||||
<string name="glasses_playing">Играете на %1$s</string>
|
||||
<string name="glasses_phone_recenter">Центриране отново</string>
|
||||
<string name="glasses_phone_steamvr_menu">Меню на SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Стикове: ръце ⇄ игра</string>
|
||||
<string name="glasses_phone_controllers">Контролери: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Клавиатура</string>
|
||||
<string name="glasses_phone_session_menu">Меню на сесията</string>
|
||||
<string name="glasses_phone_stop_steamvr">Спиране на SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Яркост %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Батерия %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Калибриране на проследяването</string>
|
||||
<string name="glasses_calibration_hint">Намалява дрейфа и подравнява хоризонта.</string>
|
||||
<string name="glasses_calibration_saved_hint">За тези очила се прилага запазено калибриране.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Jednou kliknout</string>
|
||||
<string name="gesture_hold_delay">Prodleva podržení</string>
|
||||
<string name="gesture_double_delay">Interval dvojitého klepnutí</string>
|
||||
<string name="gesture_scope_game">Platí pro aktuální hru</string>
|
||||
<string name="gesture_scope_default">Výchozí nastavení pro všechny hry</string>
|
||||
<string name="gesture_reset">Obnovit původní nastavení gest</string>
|
||||
<string name="gesture_cancel">Zrušit</string>
|
||||
<string name="gesture_action">Vybrat akci</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Kolečko myši</string>
|
||||
<string name="gesture_threshold">Práh gest: %1$d px</string>
|
||||
<string name="gesture_debug">Zobrazovat rozpoznané gesto</string>
|
||||
<string name="gesture_editor_subtitle">Ovládání myši a klávesnice uspořádané podle gest</string>
|
||||
<string name="gesture_tab_one">Jeden prst</string>
|
||||
<string name="gesture_tab_two">Dva prsty</string>
|
||||
<string name="gesture_tab_three">Tři prsty</string>
|
||||
<string name="gesture_tab_options">Možnosti</string>
|
||||
<string name="gesture_one_hint">Vyberte akci pro klepnutí, tažení nebo podržení.</string>
|
||||
<string name="gesture_two_hint">Nastavte posouvání kamery, přiblížení a vedlejší kliknutí.</string>
|
||||
<string name="gesture_three_hint">Mějte další zkratky na dosah.</string>
|
||||
<string name="gesture_options_hint">Upravte pohyb, rozpoznávání a vizuální odezvu.</string>
|
||||
<string name="gesture_defaults">Výchozí</string>
|
||||
<string name="gesture_game">Tato hra</string>
|
||||
<string name="gesture_use_defaults">Použít výchozí</string>
|
||||
<string name="gesture_inheriting">Používá se výchozí nastavení. Úpravou vznikne nastavení pro tuto hru.</string>
|
||||
<string name="gesture_save_changes">Uložit změny</string>
|
||||
<string name="gesture_close">Zavřít</string>
|
||||
<string name="gesture_action_label">AKCE</string>
|
||||
<string name="gesture_action_for">Pro %1$s</string>
|
||||
<string name="gesture_search_actions">Hledat akce myši nebo klávesy</string>
|
||||
<string name="gesture_clear_search">Vymazat hledání</string>
|
||||
<string name="gesture_all_actions">Vše</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Numerická klávesnice</string>
|
||||
<string name="gesture_group_symbols">Symboly</string>
|
||||
<string name="gesture_movement">Pohyb myši</string>
|
||||
<string name="gesture_movement_hint">Přímé tažení sleduje polohu dotyku. Relativní tažení sleduje pohyb prstu a hodí se, když hra vrací myš do středu.</string>
|
||||
<string name="gesture_middle_relative_hint">Posouvání prostředním tlačítkem vždy sleduje pohyb prstu, aby kurzor nepřeskakoval.</string>
|
||||
<string name="gesture_direct">Přímé</string>
|
||||
<string name="gesture_relative">Relativní</string>
|
||||
<string name="gesture_recognition">Rozpoznávání gest</string>
|
||||
<string name="gesture_threshold_hint">Po překonání této vzdálenosti se gesto dvěma či třemi prsty změní na posouvání nebo přiblížení.</string>
|
||||
<string name="gesture_feedback">Vizuální odezva</string>
|
||||
<string name="gesture_mouse_behavior">Chování tlačítka myši</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -508,7 +508,9 @@
|
||||
<string name="comp_delete_original_title">Smazat tento originál?</string>
|
||||
<string name="comp_delete_original_body">Tento originál %1$s je z dřívějšího sestavení %2$s. Originál aktuálního sestavení je vždy zachován.</string>
|
||||
<string name="comp_nightlies_count">Na Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Obnovte pro zobrazení balíčků na Nightlies.</string>
|
||||
<string name="comp_channel_stable">Stabilní</string>
|
||||
<string name="comp_channel_nightly">Noční</string>
|
||||
<string name="comp_refresh_to_list">Obnovte pro zobrazení balíčků na Nightlies a DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Od poslední kontroly nic nového.</string>
|
||||
<string name="comp_import_wcp">Importovat .wcp</string>
|
||||
<string name="comp_restore_original">Obnovit originál tohoto Protonu</string>
|
||||
@@ -586,6 +588,16 @@
|
||||
<string name="drawer_prev_page">Předchozí stránka</string>
|
||||
<string name="drawer_next_page">Další stránka</string>
|
||||
<string name="drawer_hud">HUD výkonu</string>
|
||||
<string name="drawer_hud_fps">Snímková frekvence</string>
|
||||
<string name="drawer_hud_battery">Teplota baterie</string>
|
||||
<string name="drawer_hud_graph">Graf výkonu</string>
|
||||
<string name="drawer_hud_color">Barva</string>
|
||||
<string name="drawer_hud_color_mint">Máta</string>
|
||||
<string name="drawer_hud_color_white">Bílá</string>
|
||||
<string name="drawer_hud_color_sky">Nebeská</string>
|
||||
<string name="drawer_hud_color_amber">Ambrová</string>
|
||||
<string name="drawer_hud_color_violet">Fialová</string>
|
||||
<string name="drawer_hud_opacity">Průhlednost</string>
|
||||
<string name="drawer_scaling">Režim škálování</string>
|
||||
<string name="drawer_scaling_sharpness">Ostrost škálování</string>
|
||||
<string name="drawer_effects">Efekty obrazovky</string>
|
||||
@@ -1812,7 +1824,8 @@
|
||||
<string name="cmgr_original_kept">Původní verze nainstalovaného sestavení je zachována: je to cesta zpět.</string>
|
||||
<string name="cmgr_not_stored">Není uloženo</string>
|
||||
<string name="cmgr_deleted_original">Odstraněna původní verze %2$s z %1$s.</string>
|
||||
<string name="cmgr_nightlies_only">Stahování pochází jen z vydání Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">Stahování pochází jen z vydání Nightlies a DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Stažení selhalo: tento noční build mohl být nahrazen novějším. Obnovte seznam a zkuste to znovu.</string>
|
||||
<string name="cmgr_list_predates_checksums">Tento seznam balíčků předchází kontrolním součtům - obnovte ho a zkuste to znovu</string>
|
||||
<string name="cmgr_checksum_mismatch">Neshoda kontrolního součtu</string>
|
||||
|
||||
@@ -2068,6 +2081,9 @@
|
||||
<string name="cloud_last_sync">Poslední synchronizace %1$s</string>
|
||||
<string name="cloud_never">Ještě nesynchronizováno</string>
|
||||
<string name="cloud_done">%1$d souborů</string>
|
||||
<string name="cloud_up_to_date">Už je aktuální</string>
|
||||
<string name="cloud_signed_out">Pro synchronizaci uložených pozic se přihlaste do obchodu</string>
|
||||
<string name="cloud_not_synced">Uložené pozice nebyly synchronizovány</string>
|
||||
<string name="stores_eyebrow_installed">nainstalováno</string>
|
||||
<string name="stores_eyebrow_library">ve vaší knihovně</string>
|
||||
<string name="stores_eyebrow_store">obchod</string>
|
||||
@@ -2375,7 +2391,7 @@
|
||||
<string name="touch_done">Hotovo</string>
|
||||
<string name="touch_save_failed">Dotykové rozložení se nepodařilo uložit</string>
|
||||
<string name="touch_saved">Dotykové rozložení uloženo ve Steamu</string>
|
||||
<string name="touch_app_id">Aplikace %1$d</string>
|
||||
<string name="touch_app_id">Aplikace %1$s</string>
|
||||
<string name="touch_label_hint">Popisek (nepovinné)</string>
|
||||
<string name="touch_icons_missing">Ikony Steamu nebyly nalezeny</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2470,6 +2486,15 @@
|
||||
<string name="glasses_vr_fov">Zorné pole SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">Nižší hodnota přiblíží SteamVR a vyplní brýle; vyšší ukáže víc z okolí.</string>
|
||||
<string name="glasses_playing">Hraje se na %1$s</string>
|
||||
<string name="glasses_phone_recenter">Znovu vycentrovat</string>
|
||||
<string name="glasses_phone_steamvr_menu">Nabídka SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Páčky: ruce ⇄ hra</string>
|
||||
<string name="glasses_phone_controllers">Ovladače: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Klávesnice</string>
|
||||
<string name="glasses_phone_session_menu">Nabídka relace</string>
|
||||
<string name="glasses_phone_stop_steamvr">Zastavit SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Jas %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Baterie %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Kalibrace sledování</string>
|
||||
<string name="glasses_calibration_hint">Sníží drift a vyrovná vodorovnou referenci.</string>
|
||||
<string name="glasses_calibration_saved_hint">Pro tyto brýle se používá uložená kalibrace.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Klik én gang</string>
|
||||
<string name="gesture_hold_delay">Forsinkelse før hold</string>
|
||||
<string name="gesture_double_delay">Interval for dobbelttryk</string>
|
||||
<string name="gesture_scope_game">Gælder det aktuelle spil</string>
|
||||
<string name="gesture_scope_default">Standardindstillinger for alle spil</string>
|
||||
<string name="gesture_reset">Gendan de oprindelige bevægelsesindstillinger</string>
|
||||
<string name="gesture_cancel">Annuller</string>
|
||||
<string name="gesture_action">Vælg handling</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Musehjul</string>
|
||||
<string name="gesture_threshold">Bevægelsestærskel: %1$d px</string>
|
||||
<string name="gesture_debug">Vis genkendt bevægelse</string>
|
||||
<string name="gesture_editor_subtitle">Muse- og tastaturstyring organiseret efter bevægelse</string>
|
||||
<string name="gesture_tab_one">Én finger</string>
|
||||
<string name="gesture_tab_two">To fingre</string>
|
||||
<string name="gesture_tab_three">Tre fingre</string>
|
||||
<string name="gesture_tab_options">Indstillinger</string>
|
||||
<string name="gesture_one_hint">Vælg, hvad et tryk, træk eller hold skal gøre.</string>
|
||||
<string name="gesture_two_hint">Indstil kamerapanorering, zoom og sekundære klik.</string>
|
||||
<string name="gesture_three_hint">Hav flere genveje inden for rækkevidde.</string>
|
||||
<string name="gesture_options_hint">Juster bevægelse, genkendelse og visuel feedback.</string>
|
||||
<string name="gesture_defaults">Standarder</string>
|
||||
<string name="gesture_game">Dette spil</string>
|
||||
<string name="gesture_use_defaults">Brug standardindstillinger</string>
|
||||
<string name="gesture_inheriting">Bruger standardindstillingerne. Redigering opretter særlige indstillinger for spillet.</string>
|
||||
<string name="gesture_save_changes">Gem ændringer</string>
|
||||
<string name="gesture_close">Luk</string>
|
||||
<string name="gesture_action_label">HANDLING</string>
|
||||
<string name="gesture_action_for">For %1$s</string>
|
||||
<string name="gesture_search_actions">Søg efter musehandlinger eller tastaturtaster</string>
|
||||
<string name="gesture_clear_search">Ryd søgning</string>
|
||||
<string name="gesture_all_actions">Alle</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Numerisk tastatur</string>
|
||||
<string name="gesture_group_symbols">Symboler</string>
|
||||
<string name="gesture_movement">Musebevægelse</string>
|
||||
<string name="gesture_movement_hint">Direkte træk følger berøringspositionen. Relative træk følger fingerbevægelsen og er nyttige, når et spil centrerer musen.</string>
|
||||
<string name="gesture_middle_relative_hint">Panorering med midterste museknap følger altid fingerbevægelsen for at undgå markørspring.</string>
|
||||
<string name="gesture_direct">Direkte</string>
|
||||
<string name="gesture_relative">Relativ</string>
|
||||
<string name="gesture_recognition">Genkendelse af bevægelser</string>
|
||||
<string name="gesture_threshold_hint">Bevæg fingrene så langt, før en bevægelse med to eller tre fingre bliver til panorering eller zoom.</string>
|
||||
<string name="gesture_feedback">Visuel feedback</string>
|
||||
<string name="gesture_mouse_behavior">Museknappens funktion</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -504,7 +504,9 @@
|
||||
<string name="comp_delete_original_title">Slet denne original?</string>
|
||||
<string name="comp_delete_original_body">Denne original af %1$s er fra en tidligere build af %2$s. Den aktuelle builds original bevares altid.</string>
|
||||
<string name="comp_nightlies_count">På Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Opdater for at se pakker på Nightlies.</string>
|
||||
<string name="comp_channel_stable">Stabil</string>
|
||||
<string name="comp_channel_nightly">Nightly</string>
|
||||
<string name="comp_refresh_to_list">Opdater for at se pakker på Nightlies og DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Intet nyt siden sidste kontrol.</string>
|
||||
<string name="comp_import_wcp">Importer .wcp</string>
|
||||
<string name="comp_restore_original">Gendan denne Protons original</string>
|
||||
@@ -582,6 +584,16 @@
|
||||
<string name="drawer_prev_page">Forrige side</string>
|
||||
<string name="drawer_next_page">Næste side</string>
|
||||
<string name="drawer_hud">Ydelses-HUD</string>
|
||||
<string name="drawer_hud_fps">Billedhastighed</string>
|
||||
<string name="drawer_hud_battery">Batteritemperatur</string>
|
||||
<string name="drawer_hud_graph">Ydelsesgraf</string>
|
||||
<string name="drawer_hud_color">Farve</string>
|
||||
<string name="drawer_hud_color_mint">Mint</string>
|
||||
<string name="drawer_hud_color_white">Hvid</string>
|
||||
<string name="drawer_hud_color_sky">Himmelblå</string>
|
||||
<string name="drawer_hud_color_amber">Ravgul</string>
|
||||
<string name="drawer_hud_color_violet">Violet</string>
|
||||
<string name="drawer_hud_opacity">Gennemsigtighed</string>
|
||||
<string name="drawer_scaling">Skaleringstilstand</string>
|
||||
<string name="drawer_scaling_sharpness">Skaleringsskarphed</string>
|
||||
<string name="drawer_effects">Skærmeffekter</string>
|
||||
@@ -1770,7 +1782,8 @@
|
||||
<string name="cmgr_original_kept">Originalen af den installerede version beholdes: den er vejen tilbage.</string>
|
||||
<string name="cmgr_not_stored">Ikke gemt</string>
|
||||
<string name="cmgr_deleted_original">Slettede %1$s-originalen af %2$s.</string>
|
||||
<string name="cmgr_nightlies_only">Downloads kommer kun fra Nightlies-udgivelserne</string>
|
||||
<string name="cmgr_nightlies_only">Downloads kommer kun fra Nightlies- og DroidDeck-udgivelserne</string>
|
||||
<string name="cmgr_nightly_replaced">Download mislykkedes: denne nightly kan være erstattet af en nyere build. Opdater listen, og prøv igen.</string>
|
||||
<string name="cmgr_list_predates_checksums">Denne pakkeliste er ældre end tjeksummer - opdater den, og forsøg igen</string>
|
||||
<string name="cmgr_checksum_mismatch">Tjeksum stemmer ikke</string>
|
||||
|
||||
@@ -2026,6 +2039,9 @@
|
||||
<string name="cloud_last_sync">Senest synkroniseret %1$s</string>
|
||||
<string name="cloud_never">Ikke synkroniseret endnu</string>
|
||||
<string name="cloud_done">%1$d filer</string>
|
||||
<string name="cloud_up_to_date">Allerede opdateret</string>
|
||||
<string name="cloud_signed_out">Log ind i butikken for at synkronisere gemte spil</string>
|
||||
<string name="cloud_not_synced">Gemte spil blev ikke synkroniseret</string>
|
||||
<string name="stores_eyebrow_installed">installeret</string>
|
||||
<string name="stores_eyebrow_library">i dit bibliotek</string>
|
||||
<string name="stores_eyebrow_store">butik</string>
|
||||
@@ -2329,7 +2345,7 @@
|
||||
<string name="touch_done">Udført</string>
|
||||
<string name="touch_save_failed">Berøringslayoutet kunne ikke gemmes</string>
|
||||
<string name="touch_saved">Berøringslayout gemt i Steam</string>
|
||||
<string name="touch_app_id">App %1$d</string>
|
||||
<string name="touch_app_id">App %1$s</string>
|
||||
<string name="touch_label_hint">Etiket (valgfrit)</string>
|
||||
<string name="touch_icons_missing">Steams ikoner blev ikke fundet</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2420,6 +2436,15 @@
|
||||
<string name="glasses_vr_fov">SteamVR-synsfelt</string>
|
||||
<string name="glasses_vr_fov_hint">Lavere bringer SteamVR tættere på og fylder brillen; højere viser mere omkring dig.</string>
|
||||
<string name="glasses_playing">Spiller på %1$s</string>
|
||||
<string name="glasses_phone_recenter">Centrér igen</string>
|
||||
<string name="glasses_phone_steamvr_menu">SteamVR-menu</string>
|
||||
<string name="glasses_phone_switch_sticks">Pinde: hænder ⇄ spil</string>
|
||||
<string name="glasses_phone_controllers">Controllere: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Tastatur</string>
|
||||
<string name="glasses_phone_session_menu">Sessionsmenu</string>
|
||||
<string name="glasses_phone_stop_steamvr">Stop SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Lysstyrke %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Batteri %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Sporingskalibrering</string>
|
||||
<string name="glasses_calibration_hint">Mindsker drift og justerer den vandrette reference.</string>
|
||||
<string name="glasses_calibration_saved_hint">Gemt kalibrering anvendes på denne brille.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Einmal klicken</string>
|
||||
<string name="gesture_hold_delay">Halteverzögerung</string>
|
||||
<string name="gesture_double_delay">Doppeltipp-Intervall</string>
|
||||
<string name="gesture_scope_game">Gilt für das aktuelle Spiel</string>
|
||||
<string name="gesture_scope_default">Standardeinstellungen für alle Spiele</string>
|
||||
<string name="gesture_reset">Ursprüngliche Gesteneinstellungen wiederherstellen</string>
|
||||
<string name="gesture_cancel">Abbrechen</string>
|
||||
<string name="gesture_action">Aktion auswählen</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Mausrad</string>
|
||||
<string name="gesture_threshold">Gestenschwelle: %1$d px</string>
|
||||
<string name="gesture_debug">Erkannte Geste anzeigen</string>
|
||||
<string name="gesture_editor_subtitle">Maus- und Tastatursteuerung nach Gesten geordnet</string>
|
||||
<string name="gesture_tab_one">Ein Finger</string>
|
||||
<string name="gesture_tab_two">Zwei Finger</string>
|
||||
<string name="gesture_tab_three">Drei Finger</string>
|
||||
<string name="gesture_tab_options">Optionen</string>
|
||||
<string name="gesture_one_hint">Wähle die Aktion für Tippen, Ziehen oder Halten.</string>
|
||||
<string name="gesture_two_hint">Kameraschwenks, Zoom und sekundäre Klicks anpassen.</string>
|
||||
<string name="gesture_three_hint">Zusätzliche Kurzbefehle griffbereit halten.</string>
|
||||
<string name="gesture_options_hint">Bewegung, Erkennung und visuelle Rückmeldung anpassen.</string>
|
||||
<string name="gesture_defaults">Standardwerte</string>
|
||||
<string name="gesture_game">Dieses Spiel</string>
|
||||
<string name="gesture_use_defaults">Standardeinstellungen verwenden</string>
|
||||
<string name="gesture_inheriting">Standardeinstellungen werden verwendet. Änderungen erstellen eine Spielanpassung.</string>
|
||||
<string name="gesture_save_changes">Änderungen speichern</string>
|
||||
<string name="gesture_close">Schließen</string>
|
||||
<string name="gesture_action_label">AKTION</string>
|
||||
<string name="gesture_action_for">Für %1$s</string>
|
||||
<string name="gesture_search_actions">Mausaktionen oder Tastaturtasten suchen</string>
|
||||
<string name="gesture_clear_search">Suche leeren</string>
|
||||
<string name="gesture_all_actions">Alle</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Ziffernblock</string>
|
||||
<string name="gesture_group_symbols">Symbole</string>
|
||||
<string name="gesture_movement">Mausbewegung</string>
|
||||
<string name="gesture_movement_hint">Direktes Ziehen folgt der Berührungsposition. Relatives Ziehen folgt der Fingerbewegung und eignet sich für Spiele, die die Maus zentrieren.</string>
|
||||
<string name="gesture_middle_relative_hint">Schwenks mit mittlerer Maustaste folgen immer der Fingerbewegung, um Zeigersprünge zu vermeiden.</string>
|
||||
<string name="gesture_direct">Direkt</string>
|
||||
<string name="gesture_relative">Relativ</string>
|
||||
<string name="gesture_recognition">Gestenerkennung</string>
|
||||
<string name="gesture_threshold_hint">Bewege die Finger um diese Strecke, bevor eine Zwei- oder Dreifingergeste als Schwenken oder Zoomen gilt.</string>
|
||||
<string name="gesture_feedback">Visuelle Rückmeldung</string>
|
||||
<string name="gesture_mouse_behavior">Verhalten der Maustaste</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -501,7 +501,9 @@
|
||||
<string name="comp_delete_original_title">Dieses Original löschen?</string>
|
||||
<string name="comp_delete_original_body">Dieses %1$s-Original stammt aus einem früheren Build von %2$s. Das Original des aktuellen Builds wird immer behalten.</string>
|
||||
<string name="comp_nightlies_count">In den Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Aktualisiere, um die Pakete in den Nightlies aufzulisten.</string>
|
||||
<string name="comp_channel_stable">Stabil</string>
|
||||
<string name="comp_channel_nightly">Nightly</string>
|
||||
<string name="comp_refresh_to_list">Aktualisiere, um die Pakete in den Nightlies und bei DroidDeck aufzulisten.</string>
|
||||
<string name="comp_nothing_new">Nichts Neues seit der letzten Prüfung.</string>
|
||||
<string name="comp_import_wcp">.wcp importieren</string>
|
||||
<string name="comp_restore_original">Original dieses Protons wiederherstellen</string>
|
||||
@@ -579,6 +581,16 @@
|
||||
<string name="drawer_prev_page">Vorherige Seite</string>
|
||||
<string name="drawer_next_page">Nächste Seite</string>
|
||||
<string name="drawer_hud">Leistungs-HUD</string>
|
||||
<string name="drawer_hud_fps">Bildrate</string>
|
||||
<string name="drawer_hud_battery">Akkutemperatur</string>
|
||||
<string name="drawer_hud_graph">Leistungsdiagramm</string>
|
||||
<string name="drawer_hud_color">Farbe</string>
|
||||
<string name="drawer_hud_color_mint">Minze</string>
|
||||
<string name="drawer_hud_color_white">Weiß</string>
|
||||
<string name="drawer_hud_color_sky">Himmelblau</string>
|
||||
<string name="drawer_hud_color_amber">Bernstein</string>
|
||||
<string name="drawer_hud_color_violet">Violett</string>
|
||||
<string name="drawer_hud_opacity">Deckkraft</string>
|
||||
<string name="drawer_scaling">Skalierungsmodus</string>
|
||||
<string name="drawer_scaling_sharpness">Skalierungsschärfe</string>
|
||||
<string name="drawer_effects">Bildschirmeffekte</string>
|
||||
@@ -1759,7 +1771,8 @@
|
||||
<string name="cmgr_original_kept">Das Original des installierten Builds bleibt erhalten: Es ist der Weg zurück.</string>
|
||||
<string name="cmgr_not_stored">Nicht gespeichert</string>
|
||||
<string name="cmgr_deleted_original">Das %1$s-Original von %2$s wurde gelöscht.</string>
|
||||
<string name="cmgr_nightlies_only">Downloads kommen nur aus den Nightlies-Releases</string>
|
||||
<string name="cmgr_nightlies_only">Downloads kommen nur aus den Nightlies- und DroidDeck-Releases</string>
|
||||
<string name="cmgr_nightly_replaced">Download fehlgeschlagen: Dieser Nightly-Build wurde vielleicht durch einen neueren ersetzt. Aktualisiere die Liste und versuche es erneut.</string>
|
||||
<string name="cmgr_list_predates_checksums">Diese Paketliste stammt aus der Zeit vor Prüfsummen - aktualisiere sie und versuche es erneut</string>
|
||||
<string name="cmgr_checksum_mismatch">Prüfsumme stimmt nicht überein</string>
|
||||
|
||||
@@ -2015,6 +2028,9 @@
|
||||
<string name="cloud_last_sync">Zuletzt synchronisiert %1$s</string>
|
||||
<string name="cloud_never">Noch nicht synchronisiert</string>
|
||||
<string name="cloud_done">%1$d Dateien</string>
|
||||
<string name="cloud_up_to_date">Bereits aktuell</string>
|
||||
<string name="cloud_signed_out">Im Store anmelden, um Spielstände zu synchronisieren</string>
|
||||
<string name="cloud_not_synced">Spielstände wurden nicht synchronisiert</string>
|
||||
<string name="stores_eyebrow_installed">installiert</string>
|
||||
<string name="stores_eyebrow_library">in deiner Bibliothek</string>
|
||||
<string name="stores_eyebrow_store">Store</string>
|
||||
@@ -2315,7 +2331,7 @@
|
||||
<string name="touch_done">Fertig</string>
|
||||
<string name="touch_save_failed">Das Touch-Layout konnte nicht gespeichert werden</string>
|
||||
<string name="touch_saved">Touch-Layout in Steam gespeichert</string>
|
||||
<string name="touch_app_id">App %1$d</string>
|
||||
<string name="touch_app_id">App %1$s</string>
|
||||
<string name="touch_label_hint">Bezeichnung (optional)</string>
|
||||
<string name="touch_icons_missing">Steams Symbole wurden nicht gefunden</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2406,6 +2422,15 @@
|
||||
<string name="glasses_vr_fov">SteamVR-Sichtfeld</string>
|
||||
<string name="glasses_vr_fov_hint">Niedriger holt SteamVR näher heran und füllt die Brille; höher zeigt mehr von deiner Umgebung.</string>
|
||||
<string name="glasses_playing">Spiel läuft auf %1$s</string>
|
||||
<string name="glasses_phone_recenter">Neu zentrieren</string>
|
||||
<string name="glasses_phone_steamvr_menu">SteamVR-Menü</string>
|
||||
<string name="glasses_phone_switch_sticks">Sticks: Hände ⇄ Spiel</string>
|
||||
<string name="glasses_phone_controllers">Controller: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Tastatur</string>
|
||||
<string name="glasses_phone_session_menu">Sitzungsmenü</string>
|
||||
<string name="glasses_phone_stop_steamvr">SteamVR beenden</string>
|
||||
<string name="glasses_phone_brightness">Helligkeit %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Akku %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Tracking-Kalibrierung</string>
|
||||
<string name="glasses_calibration_hint">Verringert Drift und richtet die waagerechte Referenz aus.</string>
|
||||
<string name="glasses_calibration_saved_hint">Für diese Brille wird eine gespeicherte Kalibrierung verwendet.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Ένα κλικ</string>
|
||||
<string name="gesture_hold_delay">Καθυστέρηση κρατήματος</string>
|
||||
<string name="gesture_double_delay">Διάστημα διπλού πατήματος</string>
|
||||
<string name="gesture_scope_game">Ισχύει για το τρέχον παιχνίδι</string>
|
||||
<string name="gesture_scope_default">Προεπιλεγμένες ρυθμίσεις για όλα τα παιχνίδια</string>
|
||||
<string name="gesture_reset">Επαναφορά αρχικών ρυθμίσεων χειρονομιών</string>
|
||||
<string name="gesture_cancel">Ακύρωση</string>
|
||||
<string name="gesture_action">Επιλογή ενέργειας</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Ροδέλα ποντικιού</string>
|
||||
<string name="gesture_threshold">Όριο χειρονομιών: %1$d px</string>
|
||||
<string name="gesture_debug">Εμφάνιση αναγνωρισμένης χειρονομίας</string>
|
||||
<string name="gesture_editor_subtitle">Χειρισμοί ποντικιού και πληκτρολογίου ανά χειρονομία</string>
|
||||
<string name="gesture_tab_one">Ένα δάχτυλο</string>
|
||||
<string name="gesture_tab_two">Δύο δάχτυλα</string>
|
||||
<string name="gesture_tab_three">Τρία δάχτυλα</string>
|
||||
<string name="gesture_tab_options">Επιλογές</string>
|
||||
<string name="gesture_one_hint">Επιλέξτε την ενέργεια για πάτημα, σύρσιμο ή κράτημα.</string>
|
||||
<string name="gesture_two_hint">Ρυθμίστε τη μετατόπιση της κάμερας, τη μεγέθυνση και τα δευτερεύοντα κλικ.</string>
|
||||
<string name="gesture_three_hint">Κρατήστε επιπλέον συντομεύσεις κοντά σας.</string>
|
||||
<string name="gesture_options_hint">Ρυθμίστε την κίνηση, την αναγνώριση και την οπτική ανάδραση.</string>
|
||||
<string name="gesture_defaults">Προεπιλογές</string>
|
||||
<string name="gesture_game">Αυτό το παιχνίδι</string>
|
||||
<string name="gesture_use_defaults">Χρήση προεπιλογών</string>
|
||||
<string name="gesture_inheriting">Χρησιμοποιούνται οι προεπιλογές. Η επεξεργασία δημιουργεί ρυθμίσεις για αυτό το παιχνίδι.</string>
|
||||
<string name="gesture_save_changes">Αποθήκευση αλλαγών</string>
|
||||
<string name="gesture_close">Κλείσιμο</string>
|
||||
<string name="gesture_action_label">ΕΝΕΡΓΕΙΑ</string>
|
||||
<string name="gesture_action_for">Για %1$s</string>
|
||||
<string name="gesture_search_actions">Αναζήτηση ενεργειών ποντικιού ή πλήκτρων</string>
|
||||
<string name="gesture_clear_search">Εκκαθάριση αναζήτησης</string>
|
||||
<string name="gesture_all_actions">Όλα</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Αριθμητικό πληκτρολόγιο</string>
|
||||
<string name="gesture_group_symbols">Σύμβολα</string>
|
||||
<string name="gesture_movement">Κίνηση ποντικιού</string>
|
||||
<string name="gesture_movement_hint">Το άμεσο σύρσιμο ακολουθεί τη θέση αφής. Το σχετικό σύρσιμο ακολουθεί την κίνηση του δαχτύλου, χρήσιμο όταν το παιχνίδι επαναφέρει το ποντίκι στο κέντρο.</string>
|
||||
<string name="gesture_middle_relative_hint">Η μετατόπιση με το μεσαίο κουμπί ακολουθεί πάντα την κίνηση του δαχτύλου για να αποφεύγονται άλματα του δείκτη.</string>
|
||||
<string name="gesture_direct">Απευθείας</string>
|
||||
<string name="gesture_relative">Σχετική</string>
|
||||
<string name="gesture_recognition">Αναγνώριση χειρονομιών</string>
|
||||
<string name="gesture_threshold_hint">Κινηθείτε κατά αυτή την απόσταση προτού μια χειρονομία με δύο ή τρία δάχτυλα γίνει μετατόπιση ή μεγέθυνση.</string>
|
||||
<string name="gesture_feedback">Οπτική ανάδραση</string>
|
||||
<string name="gesture_mouse_behavior">Συμπεριφορά κουμπιού ποντικιού</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -504,7 +504,9 @@
|
||||
<string name="comp_delete_original_title">Διαγραφή αυτού του πρωτοτύπου;</string>
|
||||
<string name="comp_delete_original_body">Αυτό το πρωτότυπο %1$s είναι από προηγούμενο build του %2$s. Το πρωτότυπο του τρέχοντος build διατηρείται πάντα.</string>
|
||||
<string name="comp_nightlies_count">Στα Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Ανανεώστε για να εμφανιστούν τα πακέτα στα Nightlies.</string>
|
||||
<string name="comp_channel_stable">Σταθερή</string>
|
||||
<string name="comp_channel_nightly">Νυχτερινή</string>
|
||||
<string name="comp_refresh_to_list">Ανανεώστε για να εμφανιστούν τα πακέτα στα Nightlies και στο DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Τίποτα νέο από τον τελευταίο έλεγχο.</string>
|
||||
<string name="comp_import_wcp">Εισαγωγή .wcp</string>
|
||||
<string name="comp_restore_original">Επαναφορά του πρωτοτύπου αυτού του Proton</string>
|
||||
@@ -582,6 +584,16 @@
|
||||
<string name="drawer_prev_page">Προηγούμενη σελίδα</string>
|
||||
<string name="drawer_next_page">Επόμενη σελίδα</string>
|
||||
<string name="drawer_hud">HUD απόδοσης</string>
|
||||
<string name="drawer_hud_fps">Ρυθμός καρέ</string>
|
||||
<string name="drawer_hud_battery">Θερμοκρασία μπαταρίας</string>
|
||||
<string name="drawer_hud_graph">Γράφημα απόδοσης</string>
|
||||
<string name="drawer_hud_color">Χρώμα</string>
|
||||
<string name="drawer_hud_color_mint">Μέντα</string>
|
||||
<string name="drawer_hud_color_white">Λευκό</string>
|
||||
<string name="drawer_hud_color_sky">Γαλάζιο</string>
|
||||
<string name="drawer_hud_color_amber">Κεχριμπαρί</string>
|
||||
<string name="drawer_hud_color_violet">Βιολετί</string>
|
||||
<string name="drawer_hud_opacity">Διαφάνεια</string>
|
||||
<string name="drawer_scaling">Λειτουργία κλιμάκωσης</string>
|
||||
<string name="drawer_scaling_sharpness">Οξύτητα κλιμάκωσης</string>
|
||||
<string name="drawer_effects">Εφέ οθόνης</string>
|
||||
@@ -1770,7 +1782,8 @@
|
||||
<string name="cmgr_original_kept">Το αρχικό της εγκατεστημένης έκδοσης διατηρείται: είναι ο δρόμος της επιστροφής.</string>
|
||||
<string name="cmgr_not_stored">Δεν είναι αποθηκευμένο</string>
|
||||
<string name="cmgr_deleted_original">Διαγράφηκε το αρχικό %1$s του %2$s.</string>
|
||||
<string name="cmgr_nightlies_only">Οι λήψεις προέρχονται μόνο από τις κυκλοφορίες Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">Οι λήψεις προέρχονται μόνο από τις κυκλοφορίες Nightlies και DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Η λήψη απέτυχε: αυτή η νυχτερινή έκδοση ίσως αντικαταστάθηκε από νεότερη. Ανανεώστε τη λίστα και δοκιμάστε ξανά.</string>
|
||||
<string name="cmgr_list_predates_checksums">Αυτή η λίστα πακέτων προηγείται των αθροισμάτων ελέγχου - ανανεώστε την και δοκιμάστε ξανά</string>
|
||||
<string name="cmgr_checksum_mismatch">Αναντιστοιχία αθροίσματος ελέγχου</string>
|
||||
|
||||
@@ -2026,6 +2039,9 @@
|
||||
<string name="cloud_last_sync">Τελευταίος συγχρονισμός %1$s</string>
|
||||
<string name="cloud_never">Δεν έχει συγχρονιστεί ακόμα</string>
|
||||
<string name="cloud_done">%1$d αρχεία</string>
|
||||
<string name="cloud_up_to_date">Ήδη ενημερωμένο</string>
|
||||
<string name="cloud_signed_out">Συνδεθείτε στο κατάστημα για συγχρονισμό αποθηκεύσεων</string>
|
||||
<string name="cloud_not_synced">Οι αποθηκεύσεις δεν συγχρονίστηκαν</string>
|
||||
<string name="stores_eyebrow_installed">εγκατεστημένο</string>
|
||||
<string name="stores_eyebrow_library">στη βιβλιοθήκη σας</string>
|
||||
<string name="stores_eyebrow_store">κατάστημα</string>
|
||||
@@ -2329,7 +2345,7 @@
|
||||
<string name="touch_done">Τέλος</string>
|
||||
<string name="touch_save_failed">Δεν ήταν δυνατή η αποθήκευση της διάταξης αφής</string>
|
||||
<string name="touch_saved">Η διάταξη αφής αποθηκεύτηκε στο Steam</string>
|
||||
<string name="touch_app_id">Εφαρμογή %1$d</string>
|
||||
<string name="touch_app_id">Εφαρμογή %1$s</string>
|
||||
<string name="touch_label_hint">Ετικέτα (προαιρετικό)</string>
|
||||
<string name="touch_icons_missing">Τα εικονίδια του Steam δεν βρέθηκαν</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2420,6 +2436,15 @@
|
||||
<string name="glasses_vr_fov">Οπτικό πεδίο SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">Χαμηλότερο: το SteamVR έρχεται πιο κοντά και γεμίζει τα γυαλιά· υψηλότερο: βλέπετε περισσότερα γύρω σας.</string>
|
||||
<string name="glasses_playing">Παίζετε στα %1$s</string>
|
||||
<string name="glasses_phone_recenter">Επανακεντράρισμα</string>
|
||||
<string name="glasses_phone_steamvr_menu">Μενού SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Μοχλοί: χέρια ⇄ παιχνίδι</string>
|
||||
<string name="glasses_phone_controllers">Χειριστήρια: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Πληκτρολόγιο</string>
|
||||
<string name="glasses_phone_session_menu">Μενού συνεδρίας</string>
|
||||
<string name="glasses_phone_stop_steamvr">Διακοπή SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Φωτεινότητα %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Μπαταρία %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Βαθμονόμηση παρακολούθησης</string>
|
||||
<string name="glasses_calibration_hint">Μειώνει την ολίσθηση και ευθυγραμμίζει την οριζόντια αναφορά.</string>
|
||||
<string name="glasses_calibration_saved_hint">Σε αυτά τα γυαλιά εφαρμόζεται αποθηκευμένη βαθμονόμηση.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Hacer clic una vez</string>
|
||||
<string name="gesture_hold_delay">Tiempo de pulsación sostenida</string>
|
||||
<string name="gesture_double_delay">Intervalo de doble toque</string>
|
||||
<string name="gesture_scope_game">Se aplica al juego actual</string>
|
||||
<string name="gesture_scope_default">Ajustes predeterminados para todos los juegos</string>
|
||||
<string name="gesture_reset">Restablecer los ajustes originales de gestos</string>
|
||||
<string name="gesture_cancel">Cancelar</string>
|
||||
<string name="gesture_action">Elegir acción</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Rueda del ratón</string>
|
||||
<string name="gesture_threshold">Umbral de gestos: %1$d px</string>
|
||||
<string name="gesture_debug">Mostrar gesto reconocido</string>
|
||||
<string name="gesture_editor_subtitle">Controles de ratón y teclado organizados por gesto</string>
|
||||
<string name="gesture_tab_one">Un dedo</string>
|
||||
<string name="gesture_tab_two">Dos dedos</string>
|
||||
<string name="gesture_tab_three">Tres dedos</string>
|
||||
<string name="gesture_tab_options">Opciones</string>
|
||||
<string name="gesture_one_hint">Elige qué debe hacer un toque, arrastre o pulsación sostenida.</string>
|
||||
<string name="gesture_two_hint">Configura el movimiento de cámara, el zoom y los clics secundarios.</string>
|
||||
<string name="gesture_three_hint">Ten más accesos directos al alcance de la mano.</string>
|
||||
<string name="gesture_options_hint">Ajusta el movimiento, el reconocimiento y la respuesta visual.</string>
|
||||
<string name="gesture_defaults">Predeterminados</string>
|
||||
<string name="gesture_game">Este juego</string>
|
||||
<string name="gesture_use_defaults">Usar valores predeterminados</string>
|
||||
<string name="gesture_inheriting">Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego.</string>
|
||||
<string name="gesture_save_changes">Guardar cambios</string>
|
||||
<string name="gesture_close">Cerrar</string>
|
||||
<string name="gesture_action_label">ACCIÓN</string>
|
||||
<string name="gesture_action_for">Para %1$s</string>
|
||||
<string name="gesture_search_actions">Buscar acciones de ratón o teclas del teclado</string>
|
||||
<string name="gesture_clear_search">Borrar búsqueda</string>
|
||||
<string name="gesture_all_actions">Todas</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Teclado numérico</string>
|
||||
<string name="gesture_group_symbols">Símbolos</string>
|
||||
<string name="gesture_movement">Movimiento del ratón</string>
|
||||
<string name="gesture_movement_hint">El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón.</string>
|
||||
<string name="gesture_middle_relative_hint">El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor.</string>
|
||||
<string name="gesture_direct">Directo</string>
|
||||
<string name="gesture_relative">Relativo</string>
|
||||
<string name="gesture_recognition">Reconocimiento de gestos</string>
|
||||
<string name="gesture_threshold_hint">Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom.</string>
|
||||
<string name="gesture_feedback">Respuesta visual</string>
|
||||
<string name="gesture_mouse_behavior">Comportamiento del botón del ratón</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
<string name="mode_client">Cliente</string>
|
||||
<string name="mode_mangoapp">Superposición de rendimiento</string>
|
||||
<string name="mode_mangoapp_hint">Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_return_after_game">Volver a DroidDeck al cerrar un juego</string>
|
||||
<string name="mode_return_after_game_hint">Cuando se cierra un juego que iniciaste desde DroidDeck u otro lanzador, Steam se cierra correctamente (primero termina de sincronizar las partidas en la nube) y vuelves allí. Desactivado: te quedas en Steam.</string>
|
||||
<string name="mode_steam_repair">Reparar el cliente de Steam</string>
|
||||
<string name="mode_steam_repair_hint">Vuelve a descargar el cliente de Steam la próxima vez que se inicie Steam. Úsalo si Steam se queda en la pantalla de carga. Se conservan los juegos, la sesión iniciada y los ajustes.</string>
|
||||
<string name="mode_steam_repair_queued">El cliente de Steam se volverá a descargar la próxima vez que se inicie Steam.</string>
|
||||
@@ -503,7 +505,9 @@
|
||||
<string name="comp_delete_original_title">¿Eliminar este original?</string>
|
||||
<string name="comp_delete_original_body">Este original de %1$s es de una versión anterior de %2$s. El original de la versión actual siempre se conserva.</string>
|
||||
<string name="comp_nightlies_count">En las Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de las Nightlies.</string>
|
||||
<string name="comp_channel_stable">Estable</string>
|
||||
<string name="comp_channel_nightly">Nocturna</string>
|
||||
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de Nightlies y DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Nada nuevo desde la última comprobación.</string>
|
||||
<string name="comp_import_wcp">Importar .wcp</string>
|
||||
<string name="comp_restore_original">Restaurar el original de este Proton</string>
|
||||
@@ -581,6 +585,16 @@
|
||||
<string name="drawer_prev_page">Página anterior</string>
|
||||
<string name="drawer_next_page">Página siguiente</string>
|
||||
<string name="drawer_hud">HUD de rendimiento</string>
|
||||
<string name="drawer_hud_fps">Tasa de fotogramas</string>
|
||||
<string name="drawer_hud_battery">Temperatura de la batería</string>
|
||||
<string name="drawer_hud_graph">Gráfico de rendimiento</string>
|
||||
<string name="drawer_hud_color">Color</string>
|
||||
<string name="drawer_hud_color_mint">Menta</string>
|
||||
<string name="drawer_hud_color_white">Blanco</string>
|
||||
<string name="drawer_hud_color_sky">Celeste</string>
|
||||
<string name="drawer_hud_color_amber">Ámbar</string>
|
||||
<string name="drawer_hud_color_violet">Violeta</string>
|
||||
<string name="drawer_hud_opacity">Opacidad</string>
|
||||
<string name="drawer_scaling">Modo de escalado</string>
|
||||
<string name="drawer_scaling_sharpness">Nitidez del escalado</string>
|
||||
<string name="drawer_effects">Efectos de pantalla</string>
|
||||
@@ -1788,7 +1802,8 @@
|
||||
<string name="cmgr_original_kept">El original de la versión instalada se conserva: es el camino de vuelta.</string>
|
||||
<string name="cmgr_not_stored">No guardado</string>
|
||||
<string name="cmgr_deleted_original">Se eliminó el original de %1$s de %2$s.</string>
|
||||
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones de Nightlies y DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Error en la descarga: puede que esta compilación nocturna se haya sustituido por una más reciente. Actualiza la lista y vuelve a intentarlo.</string>
|
||||
<string name="cmgr_list_predates_checksums">Esta lista de paquetes es anterior a las sumas de verificación - actualízala e inténtalo de nuevo</string>
|
||||
<string name="cmgr_checksum_mismatch">La suma de verificación no coincide</string>
|
||||
|
||||
@@ -2044,6 +2059,9 @@
|
||||
<string name="cloud_last_sync">Última sincronización %1$s</string>
|
||||
<string name="cloud_never">Aún sin sincronizar</string>
|
||||
<string name="cloud_done">%1$d archivos</string>
|
||||
<string name="cloud_up_to_date">Ya está al día</string>
|
||||
<string name="cloud_signed_out">Inicia sesión en la tienda para sincronizar las partidas</string>
|
||||
<string name="cloud_not_synced">No se sincronizaron las partidas</string>
|
||||
<string name="stores_eyebrow_installed">instalado</string>
|
||||
<string name="stores_eyebrow_library">en tu biblioteca</string>
|
||||
<string name="stores_eyebrow_store">tienda</string>
|
||||
@@ -2346,7 +2364,7 @@
|
||||
<string name="touch_done">Hecho</string>
|
||||
<string name="touch_save_failed">No se pudo guardar la disposición táctil</string>
|
||||
<string name="touch_saved">Disposición táctil guardada en Steam</string>
|
||||
<string name="touch_app_id">App %1$d</string>
|
||||
<string name="touch_app_id">App %1$s</string>
|
||||
<string name="touch_label_hint">Etiqueta (opcional)</string>
|
||||
<string name="touch_icons_missing">No se encontraron los iconos de Steam</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2428,6 +2446,15 @@
|
||||
<string name="glasses_vr_fov">Campo de visión de SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor.</string>
|
||||
<string name="glasses_playing">Jugando en %1$s</string>
|
||||
<string name="glasses_phone_recenter">Recentrar</string>
|
||||
<string name="glasses_phone_steamvr_menu">Menú de SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Sticks: manos ⇄ juego</string>
|
||||
<string name="glasses_phone_controllers">Mandos: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Teclado</string>
|
||||
<string name="glasses_phone_session_menu">Menú de sesión</string>
|
||||
<string name="glasses_phone_stop_steamvr">Detener SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Brillo %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Batería %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Calibración del seguimiento</string>
|
||||
<string name="glasses_calibration_hint">Reduce la deriva y alinea la referencia horizontal.</string>
|
||||
<string name="glasses_calibration_saved_hint">Estas gafas usan la calibración guardada.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Napsauta kerran</string>
|
||||
<string name="gesture_hold_delay">Pidon viive</string>
|
||||
<string name="gesture_double_delay">Kaksoisnapautuksen aikaväli</string>
|
||||
<string name="gesture_scope_game">Koskee nykyistä peliä</string>
|
||||
<string name="gesture_scope_default">Kaikkien pelien oletusasetukset</string>
|
||||
<string name="gesture_reset">Palauta alkuperäiset eleasetukset</string>
|
||||
<string name="gesture_cancel">Peruuta</string>
|
||||
<string name="gesture_action">Valitse toiminto</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Hiiren rulla</string>
|
||||
<string name="gesture_threshold">Elekynnys: %1$d px</string>
|
||||
<string name="gesture_debug">Näytä tunnistettu ele</string>
|
||||
<string name="gesture_editor_subtitle">Hiiri- ja näppäimistöohjaus eleittäin</string>
|
||||
<string name="gesture_tab_one">Yksi sormi</string>
|
||||
<string name="gesture_tab_two">Kaksi sormea</string>
|
||||
<string name="gesture_tab_three">Kolme sormea</string>
|
||||
<string name="gesture_tab_options">Asetukset</string>
|
||||
<string name="gesture_one_hint">Valitse napautuksen, vedon tai pidon toiminto.</string>
|
||||
<string name="gesture_two_hint">Määritä kameran panorointi, zoomaus ja toissijaiset napsautukset.</string>
|
||||
<string name="gesture_three_hint">Pidä lisäpikakomennot ulottuvillasi.</string>
|
||||
<string name="gesture_options_hint">Säädä liikettä, tunnistusta ja visuaalista palautetta.</string>
|
||||
<string name="gesture_defaults">Oletukset</string>
|
||||
<string name="gesture_game">Tämä peli</string>
|
||||
<string name="gesture_use_defaults">Käytä oletuksia</string>
|
||||
<string name="gesture_inheriting">Oletusasetukset ovat käytössä. Muokkaaminen luo pelikohtaiset asetukset.</string>
|
||||
<string name="gesture_save_changes">Tallenna muutokset</string>
|
||||
<string name="gesture_close">Sulje</string>
|
||||
<string name="gesture_action_label">TOIMINTO</string>
|
||||
<string name="gesture_action_for">Kohteelle %1$s</string>
|
||||
<string name="gesture_search_actions">Etsi hiiritoimintoja tai näppäimiä</string>
|
||||
<string name="gesture_clear_search">Tyhjennä haku</string>
|
||||
<string name="gesture_all_actions">Kaikki</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Numeronäppäimistö</string>
|
||||
<string name="gesture_group_symbols">Symbolit</string>
|
||||
<string name="gesture_movement">Hiiren liike</string>
|
||||
<string name="gesture_movement_hint">Suora veto seuraa kosketuskohtaa. Suhteellinen veto seuraa sormen liikettä ja sopii peleihin, jotka keskittävät hiiren.</string>
|
||||
<string name="gesture_middle_relative_hint">Panorointi keskipainikkeella seuraa aina sormen liikettä, jotta osoitin ei hypi.</string>
|
||||
<string name="gesture_direct">Suora</string>
|
||||
<string name="gesture_relative">Suhteellinen</string>
|
||||
<string name="gesture_recognition">Eleiden tunnistus</string>
|
||||
<string name="gesture_threshold_hint">Liikuta sormia tämän matkan verran, ennen kuin kahden tai kolmen sormen ele muuttuu panoroinniksi tai zoomaukseksi.</string>
|
||||
<string name="gesture_feedback">Visuaalinen palaute</string>
|
||||
<string name="gesture_mouse_behavior">Hiiripainikkeen toiminta</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -504,7 +504,9 @@
|
||||
<string name="comp_delete_original_title">Poistetaanko tämä alkuperäinen?</string>
|
||||
<string name="comp_delete_original_body">Tämä %1$s:n alkuperäinen on aiemmasta %2$s-versiosta. Nykyisen version alkuperäinen säilytetään aina.</string>
|
||||
<string name="comp_nightlies_count">Nightliesissä · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Päivitä nähdäksesi Nightliesin paketit.</string>
|
||||
<string name="comp_channel_stable">Vakaa</string>
|
||||
<string name="comp_channel_nightly">Yöversio</string>
|
||||
<string name="comp_refresh_to_list">Päivitä nähdäksesi Nightliesin ja DroidDeckin paketit.</string>
|
||||
<string name="comp_nothing_new">Ei mitään uutta viimeisimmän tarkistuksen jälkeen.</string>
|
||||
<string name="comp_import_wcp">Tuo .wcp</string>
|
||||
<string name="comp_restore_original">Palauta tämän Protonin alkuperäinen</string>
|
||||
@@ -582,6 +584,16 @@
|
||||
<string name="drawer_prev_page">Edellinen sivu</string>
|
||||
<string name="drawer_next_page">Seuraava sivu</string>
|
||||
<string name="drawer_hud">Suorituskyky-HUD</string>
|
||||
<string name="drawer_hud_fps">Kuvataajuus</string>
|
||||
<string name="drawer_hud_battery">Akun lämpötila</string>
|
||||
<string name="drawer_hud_graph">Suorituskykykäyrä</string>
|
||||
<string name="drawer_hud_color">Väri</string>
|
||||
<string name="drawer_hud_color_mint">Minttu</string>
|
||||
<string name="drawer_hud_color_white">Valkoinen</string>
|
||||
<string name="drawer_hud_color_sky">Taivaansininen</string>
|
||||
<string name="drawer_hud_color_amber">Meripihka</string>
|
||||
<string name="drawer_hud_color_violet">Violetti</string>
|
||||
<string name="drawer_hud_opacity">Peittävyys</string>
|
||||
<string name="drawer_scaling">Skaalaustila</string>
|
||||
<string name="drawer_scaling_sharpness">Skaalauksen terävyys</string>
|
||||
<string name="drawer_effects">Näyttötehosteet</string>
|
||||
@@ -1770,7 +1782,8 @@
|
||||
<string name="cmgr_original_kept">Asennetun version alkuperäinen säilytetään: se on tie takaisin.</string>
|
||||
<string name="cmgr_not_stored">Ei tallennettu</string>
|
||||
<string name="cmgr_deleted_original">Poistettu %2$s:n %1$s-alkuperäinen.</string>
|
||||
<string name="cmgr_nightlies_only">Lataukset tulevat vain Nightlies-julkaisuista</string>
|
||||
<string name="cmgr_nightlies_only">Lataukset tulevat vain Nightlies- ja DroidDeck-julkaisuista</string>
|
||||
<string name="cmgr_nightly_replaced">Lataus epäonnistui: tämä yöversio on ehkä korvattu uudemmalla. Päivitä luettelo ja yritä uudelleen.</string>
|
||||
<string name="cmgr_list_predates_checksums">Tämä pakettiluettelo on tarkistussummia vanhempi - päivitä se ja yritä uudelleen</string>
|
||||
<string name="cmgr_checksum_mismatch">Tarkistussumma ei täsmää</string>
|
||||
|
||||
@@ -2026,6 +2039,9 @@
|
||||
<string name="cloud_last_sync">Viimeisin synkronointi %1$s</string>
|
||||
<string name="cloud_never">Ei synkronoitu vielä</string>
|
||||
<string name="cloud_done">%1$d tiedostoa</string>
|
||||
<string name="cloud_up_to_date">Jo ajan tasalla</string>
|
||||
<string name="cloud_signed_out">Kirjaudu kauppaan synkronoidaksesi tallennukset</string>
|
||||
<string name="cloud_not_synced">Tallennuksia ei synkronoitu</string>
|
||||
<string name="stores_eyebrow_installed">asennettu</string>
|
||||
<string name="stores_eyebrow_library">kirjastossasi</string>
|
||||
<string name="stores_eyebrow_store">kauppa</string>
|
||||
@@ -2329,7 +2345,7 @@
|
||||
<string name="touch_done">Valmis</string>
|
||||
<string name="touch_save_failed">Kosketusasettelua ei voitu tallentaa</string>
|
||||
<string name="touch_saved">Kosketusasettelu tallennettu Steamiin</string>
|
||||
<string name="touch_app_id">Sovellus %1$d</string>
|
||||
<string name="touch_app_id">Sovellus %1$s</string>
|
||||
<string name="touch_label_hint">Nimike (valinnainen)</string>
|
||||
<string name="touch_icons_missing">Steamin kuvakkeita ei löytynyt</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2420,6 +2436,15 @@
|
||||
<string name="glasses_vr_fov">SteamVR:n näkökenttä</string>
|
||||
<string name="glasses_vr_fov_hint">Pienempi tuo SteamVR:n lähemmäs ja täyttää lasit; suurempi näyttää enemmän ympäriltäsi.</string>
|
||||
<string name="glasses_playing">Pelataan laitteella %1$s</string>
|
||||
<string name="glasses_phone_recenter">Keskitä uudelleen</string>
|
||||
<string name="glasses_phone_steamvr_menu">SteamVR-valikko</string>
|
||||
<string name="glasses_phone_switch_sticks">Sauvat: kädet ⇄ peli</string>
|
||||
<string name="glasses_phone_controllers">Ohjaimet: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Näppäimistö</string>
|
||||
<string name="glasses_phone_session_menu">Istuntovalikko</string>
|
||||
<string name="glasses_phone_stop_steamvr">Pysäytä SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Kirkkaus %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Akku %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Seurannan kalibrointi</string>
|
||||
<string name="glasses_calibration_hint">Vähentää ryömintää ja kohdistaa vaakatason.</string>
|
||||
<string name="glasses_calibration_saved_hint">Tallennettu kalibrointi on käytössä näissä laseissa.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Cliquer une fois</string>
|
||||
<string name="gesture_hold_delay">Délai de maintien</string>
|
||||
<string name="gesture_double_delay">Intervalle du double appui</string>
|
||||
<string name="gesture_scope_game">S\'applique au jeu actuel</string>
|
||||
<string name="gesture_scope_default">Paramètres par défaut pour tous les jeux</string>
|
||||
<string name="gesture_reset">Rétablir les paramètres des gestes d\'origine</string>
|
||||
<string name="gesture_cancel">Annuler</string>
|
||||
<string name="gesture_action">Choisir une action</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Molette de la souris</string>
|
||||
<string name="gesture_threshold">Seuil des gestes : %1$d px</string>
|
||||
<string name="gesture_debug">Afficher le geste reconnu</string>
|
||||
<string name="gesture_editor_subtitle">Commandes de souris et de clavier organisées par geste</string>
|
||||
<string name="gesture_tab_one">Un doigt</string>
|
||||
<string name="gesture_tab_two">Deux doigts</string>
|
||||
<string name="gesture_tab_three">Trois doigts</string>
|
||||
<string name="gesture_tab_options">Options</string>
|
||||
<string name="gesture_one_hint">Choisissez l\'action d\'un appui, d\'un glissement ou d\'un maintien.</string>
|
||||
<string name="gesture_two_hint">Configurez les déplacements de caméra, le zoom et les clics secondaires.</string>
|
||||
<string name="gesture_three_hint">Gardez des raccourcis supplémentaires à portée de main.</string>
|
||||
<string name="gesture_options_hint">Ajustez les mouvements, la reconnaissance et le retour visuel.</string>
|
||||
<string name="gesture_defaults">Valeurs par défaut</string>
|
||||
<string name="gesture_game">Ce jeu</string>
|
||||
<string name="gesture_use_defaults">Utiliser les valeurs par défaut</string>
|
||||
<string name="gesture_inheriting">Paramètres par défaut utilisés. Une modification crée des paramètres propres au jeu.</string>
|
||||
<string name="gesture_save_changes">Enregistrer les modifications</string>
|
||||
<string name="gesture_close">Fermer</string>
|
||||
<string name="gesture_action_label">ACTION</string>
|
||||
<string name="gesture_action_for">Pour %1$s</string>
|
||||
<string name="gesture_search_actions">Rechercher des actions de souris ou des touches du clavier</string>
|
||||
<string name="gesture_clear_search">Effacer la recherche</string>
|
||||
<string name="gesture_all_actions">Tout</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Pavé numérique</string>
|
||||
<string name="gesture_group_symbols">Symboles</string>
|
||||
<string name="gesture_movement">Mouvement de la souris</string>
|
||||
<string name="gesture_movement_hint">Le glissement direct suit la position du toucher. Le glissement relatif suit le mouvement du doigt, utile lorsqu\'un jeu recentre la souris.</string>
|
||||
<string name="gesture_middle_relative_hint">Le déplacement avec le bouton du milieu suit toujours le mouvement du doigt pour éviter les sauts du curseur.</string>
|
||||
<string name="gesture_direct">Direct</string>
|
||||
<string name="gesture_relative">Relatif</string>
|
||||
<string name="gesture_recognition">Reconnaissance des gestes</string>
|
||||
<string name="gesture_threshold_hint">Parcourez cette distance avant qu\'un geste à deux ou trois doigts soit reconnu comme un déplacement ou un zoom.</string>
|
||||
<string name="gesture_feedback">Retour visuel</string>
|
||||
<string name="gesture_mouse_behavior">Comportement du bouton de souris</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
<string name="mode_client">Client</string>
|
||||
<string name="mode_mangoapp">Superposition de performance</string>
|
||||
<string name="mode_mangoapp_hint">Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_return_after_game">Revenir à DroidDeck après un jeu</string>
|
||||
<string name="mode_return_after_game_hint">Quand un jeu lancé depuis DroidDeck ou un autre lanceur se ferme, Steam se ferme proprement (après la synchronisation des sauvegardes cloud) et vous y revenez. Désactivé : vous restez dans Steam.</string>
|
||||
<string name="mode_steam_repair">Réparer le client Steam</string>
|
||||
<string name="mode_steam_repair_hint">Retélécharge le client Steam au prochain démarrage de Steam. Utile si Steam reste bloqué sur son écran de chargement. Les jeux, la connexion et les paramètres sont conservés.</string>
|
||||
<string name="mode_steam_repair_queued">Le client Steam sera retéléchargé au prochain démarrage de Steam.</string>
|
||||
@@ -503,7 +505,9 @@
|
||||
<string name="comp_delete_original_title">Supprimer cette version originale ?</string>
|
||||
<string name="comp_delete_original_body">Cette version originale de %1$s provient d\'une version antérieure de %2$s. La version originale de la version actuelle sera toujours conservée.</string>
|
||||
<string name="comp_nightlies_count">Dans Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Actualiser pour lister les paquets dans Nightlies.</string>
|
||||
<string name="comp_channel_stable">Stable</string>
|
||||
<string name="comp_channel_nightly">Nightly</string>
|
||||
<string name="comp_refresh_to_list">Actualiser pour lister les paquets dans Nightlies et DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Aucun nouvel élément depuis la dernière vérification.</string>
|
||||
<string name="comp_import_wcp">Importer .wcp</string>
|
||||
<string name="comp_restore_original">Restaurer les fichiers originaux de ce Proton</string>
|
||||
@@ -581,6 +585,16 @@
|
||||
<string name="drawer_prev_page">Page précédente</string>
|
||||
<string name="drawer_next_page">Page suivante</string>
|
||||
<string name="drawer_hud">HUD de performance</string>
|
||||
<string name="drawer_hud_fps">Fréquence d\'images</string>
|
||||
<string name="drawer_hud_battery">Température de la batterie</string>
|
||||
<string name="drawer_hud_graph">Graphique de performance</string>
|
||||
<string name="drawer_hud_color">Couleur</string>
|
||||
<string name="drawer_hud_color_mint">Menthe</string>
|
||||
<string name="drawer_hud_color_white">Blanc</string>
|
||||
<string name="drawer_hud_color_sky">Ciel</string>
|
||||
<string name="drawer_hud_color_amber">Ambre</string>
|
||||
<string name="drawer_hud_color_violet">Violet</string>
|
||||
<string name="drawer_hud_opacity">Opacité</string>
|
||||
<string name="drawer_scaling">Mode de mise à l\'échelle</string>
|
||||
<string name="drawer_scaling_sharpness">Netteté de la mise à l\'échelle</string>
|
||||
<string name="drawer_effects">Effets d\'écran</string>
|
||||
@@ -1788,7 +1802,8 @@
|
||||
<string name="cmgr_original_kept">L\'original de la version installée est conservé : c\'est le moyen d\'y revenir.</string>
|
||||
<string name="cmgr_not_stored">Non enregistré</string>
|
||||
<string name="cmgr_deleted_original">Original %1$s de %2$s supprimé.</string>
|
||||
<string name="cmgr_nightlies_only">Les téléchargements proviennent uniquement des versions Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">Les téléchargements proviennent uniquement des versions Nightlies et DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Échec du téléchargement : cette version nightly a peut-être été remplacée par une plus récente. Actualisez la liste et réessayez.</string>
|
||||
<string name="cmgr_list_predates_checksums">Cette liste de paquets est antérieure aux sommes de contrôle - actualisez-la et réessayez</string>
|
||||
<string name="cmgr_checksum_mismatch">Somme de contrôle incorrecte</string>
|
||||
|
||||
@@ -2044,6 +2059,9 @@
|
||||
<string name="cloud_last_sync">Dernière synchro : %1$s</string>
|
||||
<string name="cloud_never">Pas encore synchronisé</string>
|
||||
<string name="cloud_done">%1$d fichiers</string>
|
||||
<string name="cloud_up_to_date">Déjà à jour</string>
|
||||
<string name="cloud_signed_out">Connectez-vous à la boutique pour synchroniser les sauvegardes</string>
|
||||
<string name="cloud_not_synced">Les sauvegardes n’ont pas été synchronisées</string>
|
||||
<string name="stores_eyebrow_installed">installé</string>
|
||||
<string name="stores_eyebrow_library">dans votre bibliothèque</string>
|
||||
<string name="stores_eyebrow_store">boutique</string>
|
||||
@@ -2346,7 +2364,7 @@
|
||||
<string name="touch_done">Terminé</string>
|
||||
<string name="touch_save_failed">Impossible d\'enregistrer la disposition tactile</string>
|
||||
<string name="touch_saved">Disposition tactile enregistrée sur Steam</string>
|
||||
<string name="touch_app_id">Appli %1$d</string>
|
||||
<string name="touch_app_id">Appli %1$s</string>
|
||||
<string name="touch_label_hint">Libellé (facultatif)</string>
|
||||
<string name="touch_icons_missing">Les icônes de Steam n\'ont pas été trouvées</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2439,6 +2457,15 @@
|
||||
<string name="glasses_vr_fov">Champ de vision SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">Plus bas rapproche SteamVR et remplit les lunettes ; plus haut montre davantage autour de vous.</string>
|
||||
<string name="glasses_playing">En jeu sur %1$s</string>
|
||||
<string name="glasses_phone_recenter">Recentrer</string>
|
||||
<string name="glasses_phone_steamvr_menu">Menu SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Sticks : mains ⇄ jeu</string>
|
||||
<string name="glasses_phone_controllers">Manettes : %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Clavier</string>
|
||||
<string name="glasses_phone_session_menu">Menu de session</string>
|
||||
<string name="glasses_phone_stop_steamvr">Arrêter SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Luminosité %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Batterie %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Calibrage du suivi</string>
|
||||
<string name="glasses_calibration_hint">Réduit la dérive et aligne la référence horizontale.</string>
|
||||
<string name="glasses_calibration_saved_hint">Le calibrage enregistré est appliqué à ces lunettes.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">एक बार क्लिक करें</string>
|
||||
<string name="gesture_hold_delay">दबाए रखने का विलंब</string>
|
||||
<string name="gesture_double_delay">दो टैप के बीच का अंतराल</string>
|
||||
<string name="gesture_scope_game">मौजूदा गेम पर लागू होता है</string>
|
||||
<string name="gesture_scope_default">सभी गेम की डिफ़ॉल्ट सेटिंग</string>
|
||||
<string name="gesture_reset">जेस्चर की मूल सेटिंग वापस लाएँ</string>
|
||||
<string name="gesture_cancel">रद्द करें</string>
|
||||
<string name="gesture_action">कार्रवाई चुनें</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">माउस व्हील</string>
|
||||
<string name="gesture_threshold">जेस्चर की सीमा: %1$d पिक्सेल</string>
|
||||
<string name="gesture_debug">पहचाना गया जेस्चर दिखाएँ</string>
|
||||
<string name="gesture_editor_subtitle">जेस्चर के अनुसार माउस और कीबोर्ड कंट्रोल</string>
|
||||
<string name="gesture_tab_one">एक उँगली</string>
|
||||
<string name="gesture_tab_two">दो उँगलियाँ</string>
|
||||
<string name="gesture_tab_three">तीन उँगलियाँ</string>
|
||||
<string name="gesture_tab_options">विकल्प</string>
|
||||
<string name="gesture_one_hint">चुनें कि टैप करने, खींचने या दबाए रखने पर क्या हो।</string>
|
||||
<string name="gesture_two_hint">कैमरा खिसकाना, ज़ूम और दूसरे क्लिक सेट करें।</string>
|
||||
<string name="gesture_three_hint">अतिरिक्त शॉर्टकट आसानी से इस्तेमाल करें।</string>
|
||||
<string name="gesture_options_hint">गति, पहचान और दृश्य प्रतिक्रिया में बदलाव करें।</string>
|
||||
<string name="gesture_defaults">डिफ़ॉल्ट</string>
|
||||
<string name="gesture_game">यह गेम</string>
|
||||
<string name="gesture_use_defaults">डिफ़ॉल्ट इस्तेमाल करें</string>
|
||||
<string name="gesture_inheriting">डिफ़ॉल्ट सेटिंग इस्तेमाल हो रही हैं। बदलाव करने पर इस गेम की अलग सेटिंग बनेंगी।</string>
|
||||
<string name="gesture_save_changes">बदलाव सेव करें</string>
|
||||
<string name="gesture_close">बंद करें</string>
|
||||
<string name="gesture_action_label">कार्रवाई</string>
|
||||
<string name="gesture_action_for">%1$s के लिए</string>
|
||||
<string name="gesture_search_actions">माउस की कार्रवाइयाँ या कीबोर्ड की कुंजियाँ खोजें</string>
|
||||
<string name="gesture_clear_search">खोज मिटाएँ</string>
|
||||
<string name="gesture_all_actions">सभी</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">नंबर पैड</string>
|
||||
<string name="gesture_group_symbols">चिह्न</string>
|
||||
<string name="gesture_movement">माउस की गति</string>
|
||||
<string name="gesture_movement_hint">सीधे खींचना छुई गई जगह के अनुसार चलता है। सापेक्ष खींचना उँगली की गति के अनुसार चलता है और गेम के माउस को बीच में लाने पर उपयोगी है।</string>
|
||||
<string name="gesture_middle_relative_hint">माउस के बीच वाले बटन से दृश्य खिसकाना हमेशा उँगली की गति के अनुसार चलता है, ताकि कर्सर न उछले।</string>
|
||||
<string name="gesture_direct">डायरेक्ट</string>
|
||||
<string name="gesture_relative">सापेक्ष</string>
|
||||
<string name="gesture_recognition">जेस्चर की पहचान</string>
|
||||
<string name="gesture_threshold_hint">दो या तीन उँगलियों का जेस्चर दृश्य खिसकाने या ज़ूम में बदलने से पहले इतनी दूरी तय करें।</string>
|
||||
<string name="gesture_feedback">दृश्य प्रतिक्रिया</string>
|
||||
<string name="gesture_mouse_behavior">माउस बटन का व्यवहार</string>
|
||||
<string name="gesture_ms">%1$d मिलीसेकंड</string>
|
||||
|
||||
@@ -504,7 +504,9 @@
|
||||
<string name="comp_delete_original_title">यह ओरिजिनल हटाएं?</string>
|
||||
<string name="comp_delete_original_body">यह %1$s ओरिजिनल %2$s के पहले वाले बिल्ड से है। मौजूदा बिल्ड का ओरिजिनल हमेशा रखा जाता है।</string>
|
||||
<string name="comp_nightlies_count">Nightlies पर · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Nightlies के पैकेज सूचीबद्ध करने के लिए रीफ़्रेश करें।</string>
|
||||
<string name="comp_channel_stable">स्थिर</string>
|
||||
<string name="comp_channel_nightly">नाइटली</string>
|
||||
<string name="comp_refresh_to_list">Nightlies और DroidDeck के पैकेज सूचीबद्ध करने के लिए रीफ़्रेश करें।</string>
|
||||
<string name="comp_nothing_new">पिछली जांच के बाद कुछ नया नहीं है।</string>
|
||||
<string name="comp_import_wcp">.wcp इंपोर्ट करें</string>
|
||||
<string name="comp_restore_original">इस Proton का ओरिजिनल वापस लाएं</string>
|
||||
@@ -582,6 +584,16 @@
|
||||
<string name="drawer_prev_page">पिछला पेज</string>
|
||||
<string name="drawer_next_page">अगला पेज</string>
|
||||
<string name="drawer_hud">परफ़ॉर्मेंस HUD</string>
|
||||
<string name="drawer_hud_fps">फ़्रेम दर</string>
|
||||
<string name="drawer_hud_battery">बैटरी तापमान</string>
|
||||
<string name="drawer_hud_graph">परफ़ॉर्मेंस ग्राफ़</string>
|
||||
<string name="drawer_hud_color">रंग</string>
|
||||
<string name="drawer_hud_color_mint">मिंट</string>
|
||||
<string name="drawer_hud_color_white">सफ़ेद</string>
|
||||
<string name="drawer_hud_color_sky">स्काई</string>
|
||||
<string name="drawer_hud_color_amber">एम्बर</string>
|
||||
<string name="drawer_hud_color_violet">वायलेट</string>
|
||||
<string name="drawer_hud_opacity">ओपैसिटी</string>
|
||||
<string name="drawer_scaling">स्केलिंग मोड</string>
|
||||
<string name="drawer_scaling_sharpness">स्केलिंग शार्पनेस</string>
|
||||
<string name="drawer_effects">स्क्रीन इफ़ेक्ट</string>
|
||||
@@ -1770,7 +1782,8 @@
|
||||
<string name="cmgr_original_kept">इंस्टॉल किए बिल्ड का ओरिजिनल रखा गया है: यह वापस जाने का रास्ता है।</string>
|
||||
<string name="cmgr_not_stored">सेव नहीं है</string>
|
||||
<string name="cmgr_deleted_original">%2$s का %1$s ओरिजिनल हटाया गया।</string>
|
||||
<string name="cmgr_nightlies_only">डाउनलोड सिर्फ़ Nightlies रिलीज़ से आते हैं</string>
|
||||
<string name="cmgr_nightlies_only">डाउनलोड सिर्फ़ Nightlies और DroidDeck रिलीज़ से आते हैं</string>
|
||||
<string name="cmgr_nightly_replaced">डाउनलोड विफल: हो सकता है यह नाइटली बिल्ड किसी नए बिल्ड से बदल दिया गया हो। सूची रीफ़्रेश करें और फिर से कोशिश करें।</string>
|
||||
<string name="cmgr_list_predates_checksums">यह पैकेज लिस्ट चेकसम से पुरानी है - इसे रीफ़्रेश करें और फिर कोशिश करें</string>
|
||||
<string name="cmgr_checksum_mismatch">चेकसम मेल नहीं खाया</string>
|
||||
|
||||
@@ -2026,6 +2039,9 @@
|
||||
<string name="cloud_last_sync">आख़िरी सिंक %1$s</string>
|
||||
<string name="cloud_never">अभी सिंक नहीं हुआ</string>
|
||||
<string name="cloud_done">%1$d फ़ाइलें</string>
|
||||
<string name="cloud_up_to_date">पहले से अप टू डेट</string>
|
||||
<string name="cloud_signed_out">सेव सिंक करने के लिए स्टोर में साइन इन करें</string>
|
||||
<string name="cloud_not_synced">सेव सिंक नहीं हुए</string>
|
||||
<string name="stores_eyebrow_installed">इंस्टॉल है</string>
|
||||
<string name="stores_eyebrow_library">आपकी लाइब्रेरी में</string>
|
||||
<string name="stores_eyebrow_store">स्टोर</string>
|
||||
@@ -2329,7 +2345,7 @@
|
||||
<string name="touch_done">हो गया</string>
|
||||
<string name="touch_save_failed">टच लेआउट सेव नहीं हो सका</string>
|
||||
<string name="touch_saved">टच लेआउट Steam में सेव हो गया</string>
|
||||
<string name="touch_app_id">ऐप %1$d</string>
|
||||
<string name="touch_app_id">ऐप %1$s</string>
|
||||
<string name="touch_label_hint">लेबल (वैकल्पिक)</string>
|
||||
<string name="touch_icons_missing">Steam के आइकॉन नहीं मिले</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2420,6 +2436,15 @@
|
||||
<string name="glasses_vr_fov">SteamVR फ़ील्ड ऑफ़ व्यू</string>
|
||||
<string name="glasses_vr_fov_hint">कम करने पर SteamVR पास आता है और चश्मा भर देता है; ज़्यादा करने पर आपके आस-पास ज़्यादा दिखता है।</string>
|
||||
<string name="glasses_playing">%1$s पर खेल रहे हैं</string>
|
||||
<string name="glasses_phone_recenter">फिर से केंद्रित करें</string>
|
||||
<string name="glasses_phone_steamvr_menu">SteamVR मेनू</string>
|
||||
<string name="glasses_phone_switch_sticks">स्टिक: हाथ ⇄ गेम</string>
|
||||
<string name="glasses_phone_controllers">कंट्रोलर: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">कीबोर्ड</string>
|
||||
<string name="glasses_phone_session_menu">सत्र मेनू</string>
|
||||
<string name="glasses_phone_stop_steamvr">SteamVR रोकें</string>
|
||||
<string name="glasses_phone_brightness">चमक %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">बैटरी %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">ट्रैकिंग कैलिब्रेशन</string>
|
||||
<string name="glasses_calibration_hint">ड्रिफ़्ट कम करता है और क्षैतिज संदर्भ को सीध में लाता है।</string>
|
||||
<string name="glasses_calibration_saved_hint">इस चश्मे पर सेव किया गया कैलिब्रेशन लागू है।</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Egyszeri kattintás</string>
|
||||
<string name="gesture_hold_delay">Nyomva tartás késleltetése</string>
|
||||
<string name="gesture_double_delay">Dupla koppintás időköze</string>
|
||||
<string name="gesture_scope_game">Az aktuális játékra érvényes</string>
|
||||
<string name="gesture_scope_default">Alapbeállítások minden játékhoz</string>
|
||||
<string name="gesture_reset">Eredeti gesztusbeállítások visszaállítása</string>
|
||||
<string name="gesture_cancel">Mégse</string>
|
||||
<string name="gesture_action">Művelet kiválasztása</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Egérgörgő</string>
|
||||
<string name="gesture_threshold">Gesztusküszöb: %1$d px</string>
|
||||
<string name="gesture_debug">Felismert gesztus megjelenítése</string>
|
||||
<string name="gesture_editor_subtitle">Egér- és billentyűzetvezérlés gesztusok szerint</string>
|
||||
<string name="gesture_tab_one">Egy ujj</string>
|
||||
<string name="gesture_tab_two">Két ujj</string>
|
||||
<string name="gesture_tab_three">Három ujj</string>
|
||||
<string name="gesture_tab_options">Beállítások</string>
|
||||
<string name="gesture_one_hint">Válaszd ki a koppintás, húzás vagy nyomva tartás műveletét.</string>
|
||||
<string name="gesture_two_hint">Állítsd be a kamera pásztázását, a nagyítást és a másodlagos kattintásokat.</string>
|
||||
<string name="gesture_three_hint">Tarts további gyorsparancsokat kéznél.</string>
|
||||
<string name="gesture_options_hint">Finomhangold a mozgást, a felismerést és a vizuális visszajelzést.</string>
|
||||
<string name="gesture_defaults">Alapértelmezések</string>
|
||||
<string name="gesture_game">Ez a játék</string>
|
||||
<string name="gesture_use_defaults">Alapbeállítások használata</string>
|
||||
<string name="gesture_inheriting">Az alapbeállítások érvényesek. A szerkesztés játékhoz tartozó beállításokat hoz létre.</string>
|
||||
<string name="gesture_save_changes">Módosítások mentése</string>
|
||||
<string name="gesture_close">Bezárás</string>
|
||||
<string name="gesture_action_label">MŰVELET</string>
|
||||
<string name="gesture_action_for">Ehhez: %1$s</string>
|
||||
<string name="gesture_search_actions">Egérműveletek vagy billentyűk keresése</string>
|
||||
<string name="gesture_clear_search">Keresés törlése</string>
|
||||
<string name="gesture_all_actions">Összes</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Numerikus billentyűzet</string>
|
||||
<string name="gesture_group_symbols">Szimbólumok</string>
|
||||
<string name="gesture_movement">Egérmozgás</string>
|
||||
<string name="gesture_movement_hint">A közvetlen húzás az érintési helyet követi. A relatív húzás az ujj mozgását követi, ami hasznos, ha a játék középre állítja az egeret.</string>
|
||||
<string name="gesture_middle_relative_hint">A középső egérgombos pásztázás mindig az ujj mozgását követi, hogy a kurzor ne ugorjon el.</string>
|
||||
<string name="gesture_direct">Közvetlen</string>
|
||||
<string name="gesture_relative">Relatív</string>
|
||||
<string name="gesture_recognition">Gesztusfelismerés</string>
|
||||
<string name="gesture_threshold_hint">Ennyit kell elmozdítani az ujjakat, mielőtt a két- vagy háromujjas gesztus pásztázássá vagy nagyítássá válik.</string>
|
||||
<string name="gesture_feedback">Vizuális visszajelzés</string>
|
||||
<string name="gesture_mouse_behavior">Egérgomb működése</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -504,7 +504,9 @@
|
||||
<string name="comp_delete_original_title">Törli ezt az eredetit?</string>
|
||||
<string name="comp_delete_original_body">Ez a %1$s eredeti a %2$s egy korábbi buildjéből származik. Az aktuális build eredetije mindig megmarad.</string>
|
||||
<string name="comp_nightlies_count">A Nightliesen · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Frissítsen a Nightliesen lévő csomagok listázásához.</string>
|
||||
<string name="comp_channel_stable">Stabil</string>
|
||||
<string name="comp_channel_nightly">Nightly</string>
|
||||
<string name="comp_refresh_to_list">Frissítsen a Nightlies és a DroidDeck csomagjainak listázásához.</string>
|
||||
<string name="comp_nothing_new">Nincs semmi új az utolsó ellenőrzés óta.</string>
|
||||
<string name="comp_import_wcp">.wcp importálása</string>
|
||||
<string name="comp_restore_original">Ennek a Protonnak az eredetijének visszaállítása</string>
|
||||
@@ -582,6 +584,16 @@
|
||||
<string name="drawer_prev_page">Előző oldal</string>
|
||||
<string name="drawer_next_page">Következő oldal</string>
|
||||
<string name="drawer_hud">Teljesítmény HUD</string>
|
||||
<string name="drawer_hud_fps">Képkockasebesség</string>
|
||||
<string name="drawer_hud_battery">Akkuhőmérséklet</string>
|
||||
<string name="drawer_hud_graph">Teljesítménygrafikon</string>
|
||||
<string name="drawer_hud_color">Szín</string>
|
||||
<string name="drawer_hud_color_mint">Menta</string>
|
||||
<string name="drawer_hud_color_white">Fehér</string>
|
||||
<string name="drawer_hud_color_sky">Égszínkék</string>
|
||||
<string name="drawer_hud_color_amber">Borostyán</string>
|
||||
<string name="drawer_hud_color_violet">Lila</string>
|
||||
<string name="drawer_hud_opacity">Átlátszatlanság</string>
|
||||
<string name="drawer_scaling">Méretezési mód</string>
|
||||
<string name="drawer_scaling_sharpness">Méretezési élesség</string>
|
||||
<string name="drawer_effects">Képernyőeffektek</string>
|
||||
@@ -1770,7 +1782,8 @@
|
||||
<string name="cmgr_original_kept">A telepített build eredetije megmarad: ez a visszaút.</string>
|
||||
<string name="cmgr_not_stored">Nincs tárolva</string>
|
||||
<string name="cmgr_deleted_original">A %2$s %1$s eredetije törölve.</string>
|
||||
<string name="cmgr_nightlies_only">A letöltések csak a Nightlies kiadásokból érkeznek</string>
|
||||
<string name="cmgr_nightlies_only">A letöltések csak a Nightlies és a DroidDeck kiadásokból érkeznek</string>
|
||||
<string name="cmgr_nightly_replaced">A letöltés sikertelen: lehet, hogy ezt a nightly buildet egy újabb váltotta. Frissítse a listát, és próbálja újra.</string>
|
||||
<string name="cmgr_list_predates_checksums">Ez a csomaglista régebbi, mint az ellenőrzőösszegek – frissítse, és próbálja újra</string>
|
||||
<string name="cmgr_checksum_mismatch">Az ellenőrzőösszeg nem egyezik</string>
|
||||
|
||||
@@ -2026,6 +2039,9 @@
|
||||
<string name="cloud_last_sync">Utolsó szinkronizálás: %1$s</string>
|
||||
<string name="cloud_never">Még nincs szinkronizálva</string>
|
||||
<string name="cloud_done">%1$d fájl</string>
|
||||
<string name="cloud_up_to_date">Már naprakész</string>
|
||||
<string name="cloud_signed_out">Jelentkezz be az áruházba a mentések szinkronizálásához</string>
|
||||
<string name="cloud_not_synced">A mentések nem lettek szinkronizálva</string>
|
||||
<string name="stores_eyebrow_installed">telepítve</string>
|
||||
<string name="stores_eyebrow_library">a könyvtárában</string>
|
||||
<string name="stores_eyebrow_store">áruház</string>
|
||||
@@ -2329,7 +2345,7 @@
|
||||
<string name="touch_done">Kész</string>
|
||||
<string name="touch_save_failed">Az érintőelrendezést nem sikerült elmenteni</string>
|
||||
<string name="touch_saved">Az érintőelrendezés elmentve a Steambe</string>
|
||||
<string name="touch_app_id">%1$d. alkalmazás</string>
|
||||
<string name="touch_app_id">%1$s. alkalmazás</string>
|
||||
<string name="touch_label_hint">Felirat (nem kötelező)</string>
|
||||
<string name="touch_icons_missing">A Steam ikonjai nem találhatók</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2420,6 +2436,15 @@
|
||||
<string name="glasses_vr_fov">SteamVR látómező</string>
|
||||
<string name="glasses_vr_fov_hint">Kisebb érték közelebb hozza a SteamVR-t és kitölti a szemüveget; nagyobb többet mutat a környezetéből.</string>
|
||||
<string name="glasses_playing">Játék ezen: %1$s</string>
|
||||
<string name="glasses_phone_recenter">Középre igazítás</string>
|
||||
<string name="glasses_phone_steamvr_menu">SteamVR menü</string>
|
||||
<string name="glasses_phone_switch_sticks">Karok: kezek ⇄ játék</string>
|
||||
<string name="glasses_phone_controllers">Kontrollerek: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Billentyűzet</string>
|
||||
<string name="glasses_phone_session_menu">Munkamenet menü</string>
|
||||
<string name="glasses_phone_stop_steamvr">SteamVR leállítása</string>
|
||||
<string name="glasses_phone_brightness">Fényerő %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Akkumulátor %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Követés kalibrálása</string>
|
||||
<string name="glasses_calibration_hint">Csökkenti a sodródást, és beállítja a vízszintes referenciát.</string>
|
||||
<string name="glasses_calibration_saved_hint">A szemüveg a mentett kalibrációt használja.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Klik sekali</string>
|
||||
<string name="gesture_hold_delay">Jeda sebelum menahan</string>
|
||||
<string name="gesture_double_delay">Interval ketuk dua kali</string>
|
||||
<string name="gesture_scope_game">Berlaku untuk game saat ini</string>
|
||||
<string name="gesture_scope_default">Setelan default untuk semua game</string>
|
||||
<string name="gesture_reset">Pulihkan setelan gestur awal</string>
|
||||
<string name="gesture_cancel">Batal</string>
|
||||
<string name="gesture_action">Pilih tindakan</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Roda mouse</string>
|
||||
<string name="gesture_threshold">Ambang gestur: %1$d px</string>
|
||||
<string name="gesture_debug">Tampilkan gestur yang dikenali</string>
|
||||
<string name="gesture_editor_subtitle">Kontrol mouse dan keyboard menurut gestur</string>
|
||||
<string name="gesture_tab_one">Satu jari</string>
|
||||
<string name="gesture_tab_two">Dua jari</string>
|
||||
<string name="gesture_tab_three">Tiga jari</string>
|
||||
<string name="gesture_tab_options">Opsi</string>
|
||||
<string name="gesture_one_hint">Pilih tindakan saat mengetuk, menyeret, atau menahan.</string>
|
||||
<string name="gesture_two_hint">Atur pergeseran kamera, zoom, dan klik sekunder.</string>
|
||||
<string name="gesture_three_hint">Jangkau lebih banyak pintasan dengan mudah.</string>
|
||||
<string name="gesture_options_hint">Sesuaikan gerakan, pengenalan, dan umpan balik visual.</string>
|
||||
<string name="gesture_defaults">Default</string>
|
||||
<string name="gesture_game">Game ini</string>
|
||||
<string name="gesture_use_defaults">Gunakan default</string>
|
||||
<string name="gesture_inheriting">Menggunakan setelan default. Mengedit akan membuat setelan khusus game.</string>
|
||||
<string name="gesture_save_changes">Simpan perubahan</string>
|
||||
<string name="gesture_close">Tutup</string>
|
||||
<string name="gesture_action_label">TINDAKAN</string>
|
||||
<string name="gesture_action_for">Untuk %1$s</string>
|
||||
<string name="gesture_search_actions">Cari tindakan mouse atau tombol keyboard</string>
|
||||
<string name="gesture_clear_search">Hapus pencarian</string>
|
||||
<string name="gesture_all_actions">Semua</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Keypad numerik</string>
|
||||
<string name="gesture_group_symbols">Simbol</string>
|
||||
<string name="gesture_movement">Gerakan mouse</string>
|
||||
<string name="gesture_movement_hint">Seret langsung mengikuti posisi sentuhan. Seret relatif mengikuti gerakan jari, berguna saat game memusatkan mouse.</string>
|
||||
<string name="gesture_middle_relative_hint">Pergeseran dengan tombol tengah mouse selalu mengikuti gerakan jari agar kursor tidak melompat.</string>
|
||||
<string name="gesture_direct">Langsung</string>
|
||||
<string name="gesture_relative">Relatif</string>
|
||||
<string name="gesture_recognition">Pengenalan gestur</string>
|
||||
<string name="gesture_threshold_hint">Gerakkan sejauh ini sebelum gestur dua atau tiga jari menjadi pergeseran atau zoom.</string>
|
||||
<string name="gesture_feedback">Umpan balik visual</string>
|
||||
<string name="gesture_mouse_behavior">Perilaku tombol mouse</string>
|
||||
<string name="gesture_ms">%1$d md</string>
|
||||
|
||||
@@ -502,7 +502,9 @@
|
||||
<string name="comp_delete_original_title">Hapus yang asli ini?</string>
|
||||
<string name="comp_delete_original_body">%1$s asli ini berasal dari build %2$s yang lebih lama. Asli build saat ini selalu dipertahankan.</string>
|
||||
<string name="comp_nightlies_count">Di Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Segarkan untuk melihat daftar paket di Nightlies.</string>
|
||||
<string name="comp_channel_stable">Stabil</string>
|
||||
<string name="comp_channel_nightly">Nightly</string>
|
||||
<string name="comp_refresh_to_list">Segarkan untuk melihat daftar paket di Nightlies dan DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Tidak ada yang baru sejak pemeriksaan terakhir.</string>
|
||||
<string name="comp_import_wcp">Impor .wcp</string>
|
||||
<string name="comp_restore_original">Pulihkan yang asli dari Proton ini</string>
|
||||
@@ -580,6 +582,16 @@
|
||||
<string name="drawer_prev_page">Halaman sebelumnya</string>
|
||||
<string name="drawer_next_page">Halaman berikutnya</string>
|
||||
<string name="drawer_hud">HUD performa</string>
|
||||
<string name="drawer_hud_fps">Laju bingkai</string>
|
||||
<string name="drawer_hud_battery">Suhu baterai</string>
|
||||
<string name="drawer_hud_graph">Grafik performa</string>
|
||||
<string name="drawer_hud_color">Warna</string>
|
||||
<string name="drawer_hud_color_mint">Mint</string>
|
||||
<string name="drawer_hud_color_white">Putih</string>
|
||||
<string name="drawer_hud_color_sky">Langit</string>
|
||||
<string name="drawer_hud_color_amber">Ambar</string>
|
||||
<string name="drawer_hud_color_violet">Ungu</string>
|
||||
<string name="drawer_hud_opacity">Opasitas</string>
|
||||
<string name="drawer_scaling">Mode penskalaan</string>
|
||||
<string name="drawer_scaling_sharpness">Ketajaman penskalaan</string>
|
||||
<string name="drawer_effects">Efek layar</string>
|
||||
@@ -1749,7 +1761,8 @@
|
||||
<string name="cmgr_original_kept">Yang asli dari build terinstal disimpan: itulah cara untuk kembali.</string>
|
||||
<string name="cmgr_not_stored">Tidak tersimpan</string>
|
||||
<string name="cmgr_deleted_original">Menghapus yang asli %1$s dari %2$s.</string>
|
||||
<string name="cmgr_nightlies_only">Unduhan hanya berasal dari rilis Nightlies</string>
|
||||
<string name="cmgr_nightlies_only">Unduhan hanya berasal dari rilis Nightlies dan DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Unduhan gagal: build nightly ini mungkin sudah diganti dengan yang lebih baru. Segarkan daftar lalu coba lagi.</string>
|
||||
<string name="cmgr_list_predates_checksums">Daftar paket ini dibuat sebelum adanya checksum - segarkan dan coba lagi</string>
|
||||
<string name="cmgr_checksum_mismatch">Checksum tidak cocok</string>
|
||||
|
||||
@@ -2005,6 +2018,9 @@
|
||||
<string name="cloud_last_sync">Sinkronisasi terakhir %1$s</string>
|
||||
<string name="cloud_never">Belum disinkronkan</string>
|
||||
<string name="cloud_done">%1$d file</string>
|
||||
<string name="cloud_up_to_date">Sudah terbaru</string>
|
||||
<string name="cloud_signed_out">Masuk ke toko untuk menyinkronkan simpanan</string>
|
||||
<string name="cloud_not_synced">Simpanan tidak disinkronkan</string>
|
||||
<string name="stores_eyebrow_installed">terinstal</string>
|
||||
<string name="stores_eyebrow_library">dalam pustaka Anda</string>
|
||||
<string name="stores_eyebrow_store">toko</string>
|
||||
@@ -2306,7 +2322,7 @@
|
||||
<string name="touch_done">Selesai</string>
|
||||
<string name="touch_save_failed">Tata letak sentuh tidak dapat disimpan</string>
|
||||
<string name="touch_saved">Tata letak sentuh disimpan ke Steam</string>
|
||||
<string name="touch_app_id">App %1$d</string>
|
||||
<string name="touch_app_id">App %1$s</string>
|
||||
<string name="touch_label_hint">Label (opsional)</string>
|
||||
<string name="touch_icons_missing">Ikon Steam tidak ditemukan</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2395,6 +2411,15 @@
|
||||
<string name="glasses_vr_fov">Bidang pandang SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">Lebih rendah membawa SteamVR lebih dekat dan memenuhi kacamata; lebih tinggi menampilkan lebih banyak di sekitar Anda.</string>
|
||||
<string name="glasses_playing">Bermain di %1$s</string>
|
||||
<string name="glasses_phone_recenter">Pusatkan ulang</string>
|
||||
<string name="glasses_phone_steamvr_menu">Menu SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Stik: tangan ⇄ game</string>
|
||||
<string name="glasses_phone_controllers">Kontroler: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Keyboard</string>
|
||||
<string name="glasses_phone_session_menu">Menu sesi</string>
|
||||
<string name="glasses_phone_stop_steamvr">Hentikan SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Kecerahan %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Baterai %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Kalibrasi pelacakan</string>
|
||||
<string name="glasses_calibration_hint">Mengurangi drift dan menyelaraskan acuan horizontal.</string>
|
||||
<string name="glasses_calibration_saved_hint">Kalibrasi tersimpan diterapkan ke kacamata ini.</string>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<string name="gesture_click_once">Clic singolo</string>
|
||||
<string name="gesture_hold_delay">Ritardo della pressione</string>
|
||||
<string name="gesture_double_delay">Intervallo del doppio tocco</string>
|
||||
<string name="gesture_scope_game">Si applica al gioco attuale</string>
|
||||
<string name="gesture_scope_default">Impostazioni predefinite per tutti i giochi</string>
|
||||
<string name="gesture_reset">Ripristina le impostazioni originali dei gesti</string>
|
||||
<string name="gesture_cancel">Annulla</string>
|
||||
<string name="gesture_action">Scegli azione</string>
|
||||
@@ -37,23 +35,16 @@
|
||||
<string name="gesture_scroll">Rotellina del mouse</string>
|
||||
<string name="gesture_threshold">Soglia dei gesti: %1$d px</string>
|
||||
<string name="gesture_debug">Mostra il gesto riconosciuto</string>
|
||||
<string name="gesture_editor_subtitle">Comandi di mouse e tastiera organizzati per gesto</string>
|
||||
<string name="gesture_tab_one">Un dito</string>
|
||||
<string name="gesture_tab_two">Due dita</string>
|
||||
<string name="gesture_tab_three">Tre dita</string>
|
||||
<string name="gesture_tab_options">Opzioni</string>
|
||||
<string name="gesture_one_hint">Scegli l\'azione di un tocco, trascinamento o pressione.</string>
|
||||
<string name="gesture_two_hint">Configura lo spostamento della visuale, lo zoom e i clic secondari.</string>
|
||||
<string name="gesture_three_hint">Tieni altre scorciatoie a portata di mano.</string>
|
||||
<string name="gesture_options_hint">Regola movimento, riconoscimento e riscontro visivo.</string>
|
||||
<string name="gesture_defaults">Predefiniti</string>
|
||||
<string name="gesture_game">Questo gioco</string>
|
||||
<string name="gesture_use_defaults">Usa impostazioni predefinite</string>
|
||||
<string name="gesture_inheriting">Sono in uso le impostazioni predefinite. Una modifica crea impostazioni specifiche del gioco.</string>
|
||||
<string name="gesture_save_changes">Salva modifiche</string>
|
||||
<string name="gesture_close">Chiudi</string>
|
||||
<string name="gesture_action_label">AZIONE</string>
|
||||
<string name="gesture_action_for">Per %1$s</string>
|
||||
<string name="gesture_search_actions">Cerca azioni del mouse o tasti della tastiera</string>
|
||||
<string name="gesture_clear_search">Cancella ricerca</string>
|
||||
<string name="gesture_all_actions">Tutte</string>
|
||||
@@ -68,12 +59,9 @@
|
||||
<string name="gesture_group_numpad">Tastierino numerico</string>
|
||||
<string name="gesture_group_symbols">Simboli</string>
|
||||
<string name="gesture_movement">Movimento del mouse</string>
|
||||
<string name="gesture_movement_hint">Il trascinamento diretto segue la posizione del tocco. Quello relativo segue il movimento del dito ed è utile quando un gioco ricentra il mouse.</string>
|
||||
<string name="gesture_middle_relative_hint">Lo spostamento con il pulsante centrale segue sempre il movimento del dito per evitare salti del cursore.</string>
|
||||
<string name="gesture_direct">Diretto</string>
|
||||
<string name="gesture_relative">Relativo</string>
|
||||
<string name="gesture_recognition">Riconoscimento dei gesti</string>
|
||||
<string name="gesture_threshold_hint">Percorri questa distanza prima che un gesto con due o tre dita diventi uno spostamento o uno zoom.</string>
|
||||
<string name="gesture_feedback">Riscontro visivo</string>
|
||||
<string name="gesture_mouse_behavior">Comportamento del pulsante del mouse</string>
|
||||
<string name="gesture_ms">%1$d ms</string>
|
||||
|
||||
@@ -503,7 +503,9 @@
|
||||
<string name="comp_delete_original_title">Eliminare questo originale?</string>
|
||||
<string name="comp_delete_original_body">Questo originale di %1$s proviene da una build precedente di %2$s. L\'originale della build attuale viene sempre conservato.</string>
|
||||
<string name="comp_nightlies_count">Nelle Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Aggiorna per elencare i pacchetti nelle Nightlies.</string>
|
||||
<string name="comp_channel_stable">Stabile</string>
|
||||
<string name="comp_channel_nightly">Nightly</string>
|
||||
<string name="comp_refresh_to_list">Aggiorna per elencare i pacchetti nelle Nightlies e in DroidDeck.</string>
|
||||
<string name="comp_nothing_new">Niente di nuovo dall\'ultimo controllo.</string>
|
||||
<string name="comp_import_wcp">Importa .wcp</string>
|
||||
<string name="comp_restore_original">Ripristina l\'originale di questo Proton</string>
|
||||
@@ -581,6 +583,16 @@
|
||||
<string name="drawer_prev_page">Pagina precedente</string>
|
||||
<string name="drawer_next_page">Pagina successiva</string>
|
||||
<string name="drawer_hud">HUD prestazioni</string>
|
||||
<string name="drawer_hud_fps">Frequenza fotogrammi</string>
|
||||
<string name="drawer_hud_battery">Temperatura batteria</string>
|
||||
<string name="drawer_hud_graph">Grafico prestazioni</string>
|
||||
<string name="drawer_hud_color">Colore</string>
|
||||
<string name="drawer_hud_color_mint">Menta</string>
|
||||
<string name="drawer_hud_color_white">Bianco</string>
|
||||
<string name="drawer_hud_color_sky">Cielo</string>
|
||||
<string name="drawer_hud_color_amber">Ambra</string>
|
||||
<string name="drawer_hud_color_violet">Viola</string>
|
||||
<string name="drawer_hud_opacity">Opacità</string>
|
||||
<string name="drawer_scaling">Modalità di ridimensionamento</string>
|
||||
<string name="drawer_scaling_sharpness">Nitidezza del ridimensionamento</string>
|
||||
<string name="drawer_effects">Effetti schermo</string>
|
||||
@@ -1780,7 +1792,8 @@
|
||||
<string name="cmgr_original_kept">L\'originale della build installata viene conservato: serve per tornare indietro.</string>
|
||||
<string name="cmgr_not_stored">Non salvato</string>
|
||||
<string name="cmgr_deleted_original">Originale %1$s di %2$s eliminato.</string>
|
||||
<string name="cmgr_nightlies_only">I download provengono solo dalle release Nightly</string>
|
||||
<string name="cmgr_nightlies_only">I download provengono solo dalle release Nightly e DroidDeck</string>
|
||||
<string name="cmgr_nightly_replaced">Download non riuscito: questa build nightly potrebbe essere stata sostituita da una più recente. Aggiorna l’elenco e riprova.</string>
|
||||
<string name="cmgr_list_predates_checksums">Questo elenco di pacchetti è anteriore ai checksum - aggiornalo e riprova</string>
|
||||
<string name="cmgr_checksum_mismatch">Checksum non corrispondente</string>
|
||||
|
||||
@@ -2036,6 +2049,9 @@
|
||||
<string name="cloud_last_sync">Ultima sincronizzazione %1$s</string>
|
||||
<string name="cloud_never">Non ancora sincronizzato</string>
|
||||
<string name="cloud_done">%1$d file</string>
|
||||
<string name="cloud_up_to_date">Già aggiornato</string>
|
||||
<string name="cloud_signed_out">Accedi allo store per sincronizzare i salvataggi</string>
|
||||
<string name="cloud_not_synced">I salvataggi non sono stati sincronizzati</string>
|
||||
<string name="stores_eyebrow_installed">installato</string>
|
||||
<string name="stores_eyebrow_library">nella tua libreria</string>
|
||||
<string name="stores_eyebrow_store">negozio</string>
|
||||
@@ -2338,7 +2354,7 @@
|
||||
<string name="touch_done">Fine</string>
|
||||
<string name="touch_save_failed">Impossibile salvare il layout touch</string>
|
||||
<string name="touch_saved">Layout touch salvato su Steam</string>
|
||||
<string name="touch_app_id">App %1$d</string>
|
||||
<string name="touch_app_id">App %1$s</string>
|
||||
<string name="touch_label_hint">Etichetta (opzionale)</string>
|
||||
<string name="touch_icons_missing">Le icone di Steam non sono state trovate</string>
|
||||
<plurals name="touch_icons_found">
|
||||
@@ -2431,6 +2447,15 @@
|
||||
<string name="glasses_vr_fov">Campo visivo di SteamVR</string>
|
||||
<string name="glasses_vr_fov_hint">Più basso avvicina SteamVR e riempie gli occhiali; più alto mostra di più intorno a te.</string>
|
||||
<string name="glasses_playing">Gioco in corso su %1$s</string>
|
||||
<string name="glasses_phone_recenter">Ricentra</string>
|
||||
<string name="glasses_phone_steamvr_menu">Menu di SteamVR</string>
|
||||
<string name="glasses_phone_switch_sticks">Levette: mani ⇄ gioco</string>
|
||||
<string name="glasses_phone_controllers">Controller: %1$s</string>
|
||||
<string name="glasses_phone_keyboard">Tastiera</string>
|
||||
<string name="glasses_phone_session_menu">Menu della sessione</string>
|
||||
<string name="glasses_phone_stop_steamvr">Arresta SteamVR</string>
|
||||
<string name="glasses_phone_brightness">Luminosità %1$d / %2$d</string>
|
||||
<string name="glasses_phone_battery">Batteria %1$d%% · %2$d °C</string>
|
||||
<string name="glasses_calibration">Calibrazione del tracciamento</string>
|
||||
<string name="glasses_calibration_hint">Riduce la deriva e allinea il riferimento orizzontale.</string>
|
||||
<string name="glasses_calibration_saved_hint">La calibrazione salvata è applicata a questi occhiali.</string>
|
||||
|
||||
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user