mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-21 14:44:40 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd69ed7c5f |
No files matched your search
@@ -321,17 +321,6 @@ 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
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
|
||||
}
|
||||
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
|
||||
for name in proot directaudio tracking xbox comet; do
|
||||
for name in proot directaudio vr xbox comet; do
|
||||
file="packs/$name.tar.zst"
|
||||
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
|
||||
sha=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
|
||||
|
||||
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
|
||||
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
|
||||
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" "$file" \
|
||||
|| echo "::warning::creating $tag failed; checking whether another run published it"
|
||||
digest=$(published_digest "$tag" "$name.tar.zst")
|
||||
fi
|
||||
|
||||
@@ -15,13 +15,13 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games and downloads games into the Games storage. Epic keeps its direct API install path. Opening Epic Stores prepares Epic’s official Linux launcher preview. When installed, Epic games launched from DroidDeck use that client, and games installed in the client are imported into DroidDeck’s library and shortcuts. The x86-64 client runs through FEX on ARM and DroidDeck retains its Proton hooks; this preview and ARM setup are outside Epic’s listed Linux support. The cog on a store’s chip row sets its opening tab and download speed tier.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. SteamVR on glasses, Quest and Cardboard and camera hand tracking use the **VR module**, a one-time download from Setup or the first **Play SteamVR** that Setup can uninstall again. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games and downloads games into the Games storage. Epic keeps its direct API install path. Opening Epic Stores prepares Epic’s official Linux launcher preview. When installed, Epic games launched from DroidDeck use that client, and games installed in the client are imported into DroidDeck’s library and shortcuts. The x86-64 client runs through FEX on ARM and DroidDeck retains its Proton hooks; this preview and ARM setup are outside Epic’s listed Linux support. The cog on a store’s chip row sets its opening tab and download speed tier.
|
||||
|
||||
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
## Smart glasses
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Community
|
||||
|
||||
@@ -49,7 +49,7 @@ 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). 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+6
-6
@@ -84,10 +84,10 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
|
||||
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
|
||||
def vrPack = rootProject.file('tools/packs/vr/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
|
||||
def addonPacks = ['proot', 'directaudio', 'vr', 'xbox', 'comet'].collectEntries { name ->
|
||||
def pack = file("build/packs/${name}.tar.zst")
|
||||
if (!pack.isFile()) {
|
||||
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
|
||||
@@ -195,7 +195,7 @@ android {
|
||||
|
||||
androidResources {
|
||||
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>droiddeck-vr:<dir>directaudio'
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
@@ -205,7 +205,7 @@ android {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
pickFirsts += ['**/libtermux.so']
|
||||
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
|
||||
// Shipped in the proot and VR packs (tools/packs) and run or loaded from there.
|
||||
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
dependencies {
|
||||
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
|
||||
implementation "org.opencv:opencv:${vrPack.OPENCV_VERSION}"
|
||||
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
|
||||
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
@@ -333,7 +333,7 @@ dependencies {
|
||||
implementation 'androidx.camera:camera-camera2:1.4.2'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.4.2'
|
||||
implementation 'androidx.webkit:webkit:1.12.1'
|
||||
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
|
||||
implementation("com.google.mediapipe:tasks-vision:${vrPack.MEDIAPIPE_VERSION}") {
|
||||
exclude group: 'com.google.auto.value', module: 'auto-value'
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,6 @@ 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)
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
|
||||
#include <dirent.h>
|
||||
#include <dlfcn.h>
|
||||
#ifdef __GLIBC__
|
||||
#include <gnu/lib-names.h>
|
||||
#endif
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
#include <linux/joystick.h>
|
||||
@@ -3580,21 +3577,7 @@ EXPORT int close(int fd) {
|
||||
static constexpr int kMaxRingWaitMs = 2;
|
||||
static constexpr int kLowLatencyRingWaitUs = 250;
|
||||
|
||||
static ssize_t copy_input_events(void *buf, const struct input_event *events, size_t count) {
|
||||
size_t bytes = count * sizeof(struct input_event);
|
||||
struct iovec local = {const_cast<struct input_event *>(events), bytes};
|
||||
struct iovec remote = {buf, bytes};
|
||||
ssize_t copied = syscall(SYS_process_vm_writev, getpid(), &local, 1, &remote, 1, 0);
|
||||
if (copied < 0) return -1;
|
||||
if (copied != static_cast<ssize_t>(bytes)) {
|
||||
errno = EFAULT;
|
||||
copied -= copied % sizeof(struct input_event);
|
||||
if (!copied) return -1;
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
static ssize_t read_input(int fd, void *buf, size_t count) {
|
||||
EXPORT ssize_t read(int fd, void *buf, size_t count) {
|
||||
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
auto controller = controller_map().find(fd);
|
||||
@@ -3639,44 +3622,42 @@ static ssize_t read_input(int fd, void *buf, size_t count) {
|
||||
if (events == 0) break;
|
||||
const uint8_t *ring_events = reinterpret_cast<const uint8_t *>(fake.ring) +
|
||||
FAKE_INPUT_RING_HEADER_SIZE;
|
||||
std::vector<struct input_event> input_events(events);
|
||||
for (size_t i = 0; i < events; ++i) {
|
||||
size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY;
|
||||
RingEvent stored;
|
||||
memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE);
|
||||
struct input_event &ev = input_events[i];
|
||||
struct input_event ev = {};
|
||||
ev.time.tv_sec = stored.sec;
|
||||
ev.time.tv_usec = stored.usec;
|
||||
ev.type = stored.type;
|
||||
ev.code = stored.code;
|
||||
ev.value = stored.value;
|
||||
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
|
||||
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
|
||||
}
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED))
|
||||
continue;
|
||||
ssize_t copied = copy_input_events(buf, input_events.data(), events);
|
||||
if (copied > 0) fake.read_seq += copied / sizeof(struct input_event);
|
||||
return copied;
|
||||
fake.read_seq += events;
|
||||
return static_cast<ssize_t>(events * sizeof(struct input_event));
|
||||
}
|
||||
}
|
||||
|
||||
size_t events = std::min(requested, fake.keyframe_remaining);
|
||||
struct input_event input_events[kNeutralEventCount] = {};
|
||||
struct timeval now = {};
|
||||
gettimeofday(&now, nullptr);
|
||||
for (size_t i = 0; i < events; ++i) {
|
||||
size_t index = kNeutralEventCount - fake.keyframe_remaining + i;
|
||||
struct input_event &ev = input_events[i];
|
||||
size_t index = kNeutralEventCount - fake.keyframe_remaining;
|
||||
struct input_event ev = {};
|
||||
ev.time = now;
|
||||
ev.type = kNeutralEvents[index].type;
|
||||
ev.code = kNeutralEvents[index].code;
|
||||
ev.value = keyframe_value(fake, ev.type, ev.code);
|
||||
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
|
||||
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
|
||||
--fake.keyframe_remaining;
|
||||
}
|
||||
ssize_t copied = copy_input_events(buf, input_events, events);
|
||||
if (copied > 0) fake.keyframe_remaining -= copied / sizeof(struct input_event);
|
||||
return copied;
|
||||
return static_cast<ssize_t>(events * sizeof(struct input_event));
|
||||
}
|
||||
if (nonblock) {
|
||||
errno = EAGAIN;
|
||||
@@ -3692,52 +3673,6 @@ static ssize_t read_input(int fd, void *buf, size_t count) {
|
||||
}
|
||||
}
|
||||
|
||||
EXPORT ssize_t read(int fd, void *buf, size_t count) {
|
||||
return read_input(fd, buf, count);
|
||||
}
|
||||
|
||||
EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) {
|
||||
if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt);
|
||||
if (iovcnt < 0 || iovcnt > IOV_MAX) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
if (iovcnt && !iov) {
|
||||
errno = EFAULT;
|
||||
return -1;
|
||||
}
|
||||
std::vector<struct iovec> copied_iov(static_cast<size_t>(iovcnt));
|
||||
if (iovcnt) {
|
||||
size_t bytes = copied_iov.size() * sizeof(struct iovec);
|
||||
struct iovec local = {copied_iov.data(), bytes};
|
||||
struct iovec remote = {const_cast<struct iovec *>(iov), bytes};
|
||||
ssize_t copied = syscall(SYS_process_vm_readv, getpid(), &local, 1, &remote, 1, 0);
|
||||
if (copied != static_cast<ssize_t>(bytes)) {
|
||||
errno = EFAULT;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
iov = copied_iov.data();
|
||||
size_t size = 0;
|
||||
for (int i = 0; i < iovcnt; ++i) {
|
||||
if (iov[i].iov_len > static_cast<size_t>(SSIZE_MAX) - size) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
size += iov[i].iov_len;
|
||||
}
|
||||
|
||||
ssize_t total = 0;
|
||||
for (int i = 0; i < iovcnt; ++i) {
|
||||
if (!iov[i].iov_len) continue;
|
||||
ssize_t got = read_input(fd, iov[i].iov_base, iov[i].iov_len);
|
||||
if (got < 0) return total ? total : -1;
|
||||
total += got;
|
||||
if (static_cast<size_t>(got) != iov[i].iov_len) break;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
EXPORT ssize_t write(int fd, const void *buf, size_t count) {
|
||||
static auto my_write = reinterpret_cast<decltype(&::write)>(dlsym(RTLD_NEXT, "write"));
|
||||
|
||||
@@ -4168,92 +4103,4 @@ EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) {
|
||||
if (!real) return fstat64(fd, buf);
|
||||
return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); });
|
||||
}
|
||||
|
||||
#if defined(__x86_64__) || defined(__i386__)
|
||||
using dlsym_fn = void *(*)(void *, const char *);
|
||||
|
||||
static const char *const interposed_names[] = {
|
||||
"open", "open64", "openat", "openat64", "close", "read", "readv", "write", "writev", "ioctl",
|
||||
"poll", "ppoll", "select", "stat", "stat64", "fstat", "fstat64", "__xstat", "__xstat64",
|
||||
"__fxstat", "__fxstat64", "statx", "statfs", "statfs64", "fstatfs", "fstatfs64", "access",
|
||||
"faccessat", "opendir", "scandir", "inotify_add_watch",
|
||||
};
|
||||
|
||||
static dlsym_fn next_dlsym() {
|
||||
static const dlsym_fn fn = [] {
|
||||
#ifdef __x86_64__
|
||||
const char *base = "GLIBC_2.2.5";
|
||||
#else
|
||||
const char *base = "GLIBC_2.0";
|
||||
#endif
|
||||
for (const char *version : {base, "GLIBC_2.34"}) {
|
||||
if (auto found = reinterpret_cast<dlsym_fn>(dlvsym(RTLD_NEXT, "dlsym", version))) return found;
|
||||
}
|
||||
return dlsym_fn{};
|
||||
}();
|
||||
return fn;
|
||||
}
|
||||
|
||||
static void *global_dlsym(void *, const char *name) { return next_dlsym()(RTLD_DEFAULT, name); }
|
||||
|
||||
static void *missing_dlsym(void *, const char *) { return nullptr; }
|
||||
|
||||
static bool interposed(const char *name) {
|
||||
for (const char *interposed_name : interposed_names) {
|
||||
if (strcmp(interposed_name, name) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("hidden"), used)) void *fakeinput_dlsym_target(void *handle, const char *name) {
|
||||
dlsym_fn next = next_dlsym();
|
||||
if (!next) return reinterpret_cast<void *>(missing_dlsym);
|
||||
if (handle == RTLD_DEFAULT || handle == RTLD_NEXT || name == nullptr) return reinterpret_cast<void *>(next);
|
||||
static void *const libc = dlopen(LIBC_SO, RTLD_LAZY | RTLD_NOLOAD);
|
||||
if (handle != libc || !interposed(name)) return reinterpret_cast<void *>(next);
|
||||
return reinterpret_cast<void *>(global_dlsym);
|
||||
}
|
||||
|
||||
#ifdef __x86_64__
|
||||
__asm__(".text\n"
|
||||
".globl dlsym\n"
|
||||
".type dlsym, @function\n"
|
||||
"dlsym:\n"
|
||||
".cfi_startproc\n"
|
||||
"pushq %rdi\n"
|
||||
".cfi_adjust_cfa_offset 8\n"
|
||||
"pushq %rsi\n"
|
||||
".cfi_adjust_cfa_offset 8\n"
|
||||
"subq $8, %rsp\n"
|
||||
".cfi_adjust_cfa_offset 8\n"
|
||||
"call fakeinput_dlsym_target\n"
|
||||
"addq $8, %rsp\n"
|
||||
".cfi_adjust_cfa_offset -8\n"
|
||||
"popq %rsi\n"
|
||||
".cfi_adjust_cfa_offset -8\n"
|
||||
"popq %rdi\n"
|
||||
".cfi_adjust_cfa_offset -8\n"
|
||||
"jmp *%rax\n"
|
||||
".cfi_endproc\n"
|
||||
".size dlsym, .-dlsym\n");
|
||||
#else
|
||||
__asm__(".text\n"
|
||||
".globl dlsym\n"
|
||||
".type dlsym, @function\n"
|
||||
"dlsym:\n"
|
||||
".cfi_startproc\n"
|
||||
"subl $12, %esp\n"
|
||||
".cfi_adjust_cfa_offset 12\n"
|
||||
"movl 20(%esp), %eax\n"
|
||||
"movl %eax, 4(%esp)\n"
|
||||
"movl 16(%esp), %eax\n"
|
||||
"movl %eax, (%esp)\n"
|
||||
"call fakeinput_dlsym_target\n"
|
||||
"addl $12, %esp\n"
|
||||
".cfi_adjust_cfa_offset -12\n"
|
||||
"jmp *%eax\n"
|
||||
".cfi_endproc\n"
|
||||
".size dlsym, .-dlsym\n");
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,8 +1,5 @@
|
||||
#include <errno.h>
|
||||
#include <jni.h>
|
||||
#include <stdatomic.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) {
|
||||
@@ -10,10 +7,3 @@ Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env,
|
||||
(void)clazz;
|
||||
atomic_thread_fence(memory_order_release);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_session_ThreadSignals_tgkill(JNIEnv *env, jclass clazz, jint tgid, jint tid, jint signal) {
|
||||
(void)env;
|
||||
(void)clazz;
|
||||
return syscall(SYS_tgkill, tgid, tid, signal) == 0 ? 0 : errno;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
#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
|
||||
@@ -1,346 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,11 @@ class App : Application() {
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
com.droiddeck.launcher.core.DownloadSource.init(this)
|
||||
prefetchAddonPacks()
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(this).isDirectory) {
|
||||
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(this)
|
||||
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(this)
|
||||
}
|
||||
com.droiddeck.launcher.runtime.VrPack.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
com.droiddeck.launcher.glasses.Glasses.init(this)
|
||||
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
|
||||
@@ -43,21 +47,4 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||
}
|
||||
|
||||
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
|
||||
private fun prefetchAddonPacks() {
|
||||
val app = this
|
||||
Thread({
|
||||
runCatching {
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
|
||||
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
|
||||
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
|
||||
}
|
||||
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
|
||||
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
|
||||
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
|
||||
com.droiddeck.launcher.handtracking.HandTracking.load(app)
|
||||
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
|
||||
}, "addon-packs").start()
|
||||
}
|
||||
}
|
||||
@@ -11,46 +11,29 @@ 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 deckyLatest by mutableStateOf<DeckyManager.Release?>(null)
|
||||
var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE)
|
||||
private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList())
|
||||
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(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) {
|
||||
deckyChannels = channels
|
||||
deckyLatest = DeckyManager.latest(channels, deckyBranch)
|
||||
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
|
||||
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
|
||||
@@ -66,17 +49,6 @@ 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({
|
||||
|
||||
@@ -124,7 +124,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showComponents by mutableStateOf(false)
|
||||
private var focusComponentsContent by mutableStateOf(true)
|
||||
private var componentsGameKey by mutableStateOf<String?>(null)
|
||||
private var showMapping by mutableStateOf(false)
|
||||
private var saveBusy: String? = null
|
||||
private var onSavePicked: ((File) -> Unit)? = null
|
||||
@@ -768,9 +767,6 @@ 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() } }
|
||||
@@ -864,7 +860,6 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
|
||||
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
|
||||
onGameRemoved = { g -> removeGame(g) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onSgdbKey = { key ->
|
||||
Thread({
|
||||
@@ -926,7 +921,6 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onProtons = { openProtons() },
|
||||
onComponents = { focusContent -> openComponents(focusContent) },
|
||||
onGameComponents = { game -> openComponents(gameKey = game.profileKey) },
|
||||
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
|
||||
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
|
||||
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
|
||||
@@ -1439,9 +1433,8 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
|
||||
focusComponentsContent = focusContent
|
||||
componentsGameKey = gameKey
|
||||
if (tab != null) components.compComp = tab
|
||||
showPerformance = false
|
||||
showMapping = false
|
||||
@@ -1490,7 +1483,6 @@ class MainActivity : ComponentActivity() {
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
|
||||
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
|
||||
games = steamGames,
|
||||
initialGameKey = componentsGameKey,
|
||||
generalFexPreset = fexPreset,
|
||||
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
gpu = drivers.state(),
|
||||
@@ -1649,8 +1641,7 @@ class MainActivity : ComponentActivity() {
|
||||
wifiDiscoveryAsked = wifiDiscoveryAsked,
|
||||
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null,
|
||||
deckyBranch = decky.deckyBranch,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
deckyDownloadingBinary = decky.deckyDownloadingBinary,
|
||||
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
|
||||
@@ -1733,7 +1724,6 @@ 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)
|
||||
@@ -1816,30 +1806,6 @@ class MainActivity : ComponentActivity() {
|
||||
}, "steam-art-update").start()
|
||||
}
|
||||
|
||||
private fun removeGame(g: Library.SteamGame) {
|
||||
val folder = g.gameFiles ?: return
|
||||
when (com.droiddeck.launcher.frontend.GameRemoval.kind(g)) {
|
||||
com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER -> Unit
|
||||
com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT -> removeAddedGame(folder.path, g.gameId, g.name)
|
||||
com.droiddeck.launcher.frontend.GameRemoval.Kind.STORE -> {
|
||||
val installed = com.droiddeck.launcher.frontend.GameRemoval.storeGame(g)
|
||||
if (installed != null) {
|
||||
com.droiddeck.launcher.stores.StoresState.uninstall(this, installed) { refreshAddedGames(); refresh() }
|
||||
return
|
||||
}
|
||||
if (g.library == Library.ADDED) {
|
||||
removeAddedGame(folder.path, g.gameId, g.name)
|
||||
return
|
||||
}
|
||||
val target = com.droiddeck.launcher.frontend.GameRemoval.steamFiles(g) ?: return
|
||||
Thread({
|
||||
com.droiddeck.launcher.frontend.GameRemoval.delete(target)
|
||||
ui.post { refreshAddedGames(); refresh() }
|
||||
}, "game-remove").start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
|
||||
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
|
||||
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
|
||||
|
||||
@@ -84,6 +84,8 @@ import com.droiddeck.launcher.input.TouchpadGestures
|
||||
import com.droiddeck.launcher.input.TouchscreenGestures
|
||||
import com.droiddeck.launcher.input.ScrollTouchGestures
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
@@ -306,11 +308,6 @@ 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)
|
||||
@@ -434,6 +431,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionState.steamVr = intent.getBooleanExtra(EXTRA_STEAMVR, false)
|
||||
} else if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) {
|
||||
SessionState.steamVr = true
|
||||
if (!VrPack.installed(this)) VrPack.ensure(this)
|
||||
}
|
||||
if (SessionState.steamVr) Glasses.prepareSteamVrStart()
|
||||
if (intent.action == SessionService.ACTION_AGENT_START) {
|
||||
@@ -540,7 +538,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
|
||||
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
|
||||
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
|
||||
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
|
||||
val needVr = intent.getBooleanExtra(EXTRA_STEAMVR, false) && !VrPack.installed(this)
|
||||
if (needRuntime || needResume || needDesktop || needProton || needVr) installThenStart(needRuntime, needDesktop, needProton, needResume, needVr)
|
||||
}
|
||||
hud = PerfHud(this)
|
||||
hud.onPresentingWindowChanged = {
|
||||
@@ -628,7 +627,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onDispose { com.droiddeck.launcher.glasses.GlassesMenuMirror.setSessionMenuVisible(this@SessionActivity, false) }
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
|
||||
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
|
||||
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
@@ -641,9 +639,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
} else {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density,
|
||||
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, hud.graph, hudColor, hudOpacity)
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model)
|
||||
if (trackingRecovery != null && !drawerOpen && !pcKeyboardOpen && !loading.visible) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
@@ -741,7 +739,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
achievementsInFront = found
|
||||
if (found == null) achievementsOpen = false
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads(); updateOnScreenControls() }
|
||||
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() }
|
||||
LaunchedEffect(pcKeyboardOpen, drawerOpen, achievementsOpen) {
|
||||
if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset()
|
||||
}
|
||||
@@ -757,11 +755,6 @@ 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,
|
||||
@@ -789,11 +782,6 @@ 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)
|
||||
@@ -850,7 +838,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionPrefs.setSteamController(this@SessionActivity, id)
|
||||
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
|
||||
com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id))
|
||||
padType = id
|
||||
},
|
||||
backActionsInverted = backActionsInverted,
|
||||
@@ -1358,11 +1345,6 @@ 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)
|
||||
@@ -1403,17 +1385,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
@Volatile private var installingRuntime = false
|
||||
private var repairOffered by mutableStateOf(false)
|
||||
|
||||
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
|
||||
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false, vr: Boolean = false) {
|
||||
if (SessionState.stopRequested) return
|
||||
installingRuntime = true
|
||||
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
|
||||
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else if (proton) "proton" else "vr"
|
||||
SessionEvents.transition(
|
||||
SessionPhase.INSTALLING_RUNTIME,
|
||||
"${SessionState.installing}.installing",
|
||||
mapOf("component" to SessionState.installing),
|
||||
)
|
||||
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
|
||||
loading.say(getString(first.downloading), first.topic, readable = true)
|
||||
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else if (proton) PROTON_LINES else null
|
||||
loading.say(getString(first?.downloading ?: R.string.session_vr_downloading), first?.topic ?: LoadingState.Topic.RUNTIME, readable = true)
|
||||
loading.percent = -1
|
||||
Thread({
|
||||
var failedComponent: String? = null
|
||||
@@ -1442,6 +1424,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (protonProblem == null) SessionEvents.record("proton.ready")
|
||||
else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
|
||||
}
|
||||
if (problem == null && vr && !SessionState.stopRequested) {
|
||||
SessionState.installing = "vr"
|
||||
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "vr.installing", mapOf("component" to "vr"))
|
||||
problem = installVr()
|
||||
if (problem == null) SessionEvents.record("vr.ready") else failedComponent = "vr"
|
||||
}
|
||||
uiHandler.post {
|
||||
installingRuntime = false
|
||||
SessionState.installing = null
|
||||
@@ -1456,6 +1444,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val code = when (failedComponent) {
|
||||
"runtime" -> "RUNTIME_INSTALL_FAILED"
|
||||
"package" -> "PACKAGE_RESUME_FAILED"
|
||||
"vr" -> "VR_PACK_INSTALL_FAILED"
|
||||
else -> "DESKTOP_INSTALL_FAILED"
|
||||
}
|
||||
SessionEvents.fail(code, problem)
|
||||
@@ -1529,6 +1518,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
|
||||
}
|
||||
|
||||
private fun installVr(): String? {
|
||||
val mb = VrPack.size / 1_000_000
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(R.string.session_vr_downloading), LoadingState.Topic.RUNTIME, readable = true) }
|
||||
val ok = VrPack.await(this, { !SessionState.stopRequested }) { state ->
|
||||
if (state is AddonPack.State.Downloading) uiHandler.post {
|
||||
val percent = (state.progress * 100).toInt()
|
||||
loading.percent = percent
|
||||
loading.say(getString(R.string.session_vr_downloading_mb, percent * mb / 100, mb), LoadingState.Topic.RUNTIME, readable = true)
|
||||
}
|
||||
}
|
||||
return if (ok || SessionState.stopRequested) null else getString(R.string.session_vr_install_failed)
|
||||
}
|
||||
|
||||
private fun installProton(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) }
|
||||
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
@@ -1623,7 +1625,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
|
||||
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
|
||||
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
|
||||
shownOnDisplayId,
|
||||
)
|
||||
}
|
||||
applyFrameGen()
|
||||
@@ -1674,7 +1675,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
if (started) SessionService.setActivityDisplay(this, shownOnDisplayId)
|
||||
cancelTouchGestures()
|
||||
applyFollowOrientation(newConfig)
|
||||
applyUnfoldedControls()
|
||||
@@ -1899,22 +1899,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
var enosys = 0
|
||||
var fexMissing = false
|
||||
val size = log.length()
|
||||
val text = java.io.RandomAccessFile(log, "r").use { f ->
|
||||
java.io.RandomAccessFile(log, "r").use { f ->
|
||||
val start = maxOf(0L, size - 512L * 1024)
|
||||
f.seek(start)
|
||||
val bytes = ByteArray((size - start).toInt())
|
||||
f.readFully(bytes)
|
||||
String(bytes, Charsets.ISO_8859_1)
|
||||
String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line ->
|
||||
if (line.contains("Function not implemented")) enosys++
|
||||
if (line.contains(FEX_MISSING)) fexMissing = true
|
||||
}
|
||||
}
|
||||
text.lineSequence().forEach { line ->
|
||||
if (line.contains("Function not implemented")) enosys++
|
||||
if (line.contains(FEX_MISSING)) fexMissing = true
|
||||
}
|
||||
val steamSetup = com.droiddeck.launcher.session.SteamClientSetupFailure.find(text.lineSequence())
|
||||
if (steamSetup != null) {
|
||||
val message = getString(if (steamSetup.download) R.string.session_end_steam_download else R.string.session_end_steam_setup)
|
||||
EndHint(listOfNotNull(message, steamSetup.curlError).joinToString("\n"))
|
||||
} else if (fexMissing) {
|
||||
if (fexMissing) {
|
||||
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
|
||||
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
|
||||
} else if (enosys >= 8) {
|
||||
@@ -2303,13 +2298,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
|
||||
private val shownOnDisplayId: Int get() =
|
||||
if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId
|
||||
else windowManager.defaultDisplay.displayId
|
||||
|
||||
private fun refreshSecondScreenDisplays() {
|
||||
if (!::displayManager.isInitialized) return
|
||||
val primaryId = shownOnDisplayId
|
||||
val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId
|
||||
val candidates = SecondScreenDisplays.available(displayManager, primaryId)
|
||||
.filter { glassesScreen == null || it.id != glasses.displayId }
|
||||
val oldIds = secondScreenDisplays.map { it.id }.toSet()
|
||||
@@ -2696,7 +2687,7 @@ 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) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
@@ -2728,29 +2719,25 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
val drawn = show && !keyboardCoversPad()
|
||||
if (steam != null) {
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
if (!drawn) steam.releaseAll()
|
||||
steam.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
if (!show) steam.releaseAll()
|
||||
steam.visibility = if (show) View.VISIBLE else View.GONE
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
|
||||
applyTouchRouting()
|
||||
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden")
|
||||
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
|
||||
return
|
||||
}
|
||||
if (drawn == (controls.visibility == View.VISIBLE)) return
|
||||
if (!drawn) controls.releaseAll()
|
||||
controls.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
Log.i(TAG, "on-screen controls " + when {
|
||||
!drawn -> "hidden"
|
||||
!show -> "hidden"
|
||||
buttonsOnly -> "Steam + QAM"
|
||||
else -> "full pad"
|
||||
})
|
||||
}
|
||||
|
||||
private fun keyboardCoversPad() =
|
||||
SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard
|
||||
|
||||
private fun sendGuestKey(code: Int, down: Boolean) {
|
||||
if (down && pipUi) return
|
||||
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
|
||||
@@ -2836,7 +2823,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
refreshSecondScreenDisplays()
|
||||
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
startedScreens++
|
||||
SessionService.setActivityVisible(this, true, shownOnDisplayId)
|
||||
SessionService.setActivityVisible(this, true)
|
||||
Glasses.rescan()
|
||||
updateGlassesScreen()
|
||||
updateHandCamera()
|
||||
@@ -2870,6 +2857,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) {
|
||||
SessionState.steamVr = true
|
||||
steamVrAutoTried = false
|
||||
if (!VrPack.installed(this)) VrPack.ensure(this)
|
||||
}
|
||||
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
|
||||
handleHomeGuideIntent(intent)
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import android.os.FileObserver
|
||||
import android.util.Log
|
||||
@@ -20,8 +19,6 @@ object AgentGuest {
|
||||
private val main by lazy { Handler(Looper.getMainLooper()) }
|
||||
var focusRevision by mutableIntStateOf(0)
|
||||
private set
|
||||
var steamKeyboard by mutableStateOf(false)
|
||||
private set
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
private val lock = Any()
|
||||
@@ -35,7 +32,6 @@ object AgentGuest {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
main.post { steamKeyboard = false }
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@@ -47,7 +43,6 @@ object AgentGuest {
|
||||
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
|
||||
}
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
|
||||
}
|
||||
}
|
||||
}.also { it.startWatching() }
|
||||
@@ -58,19 +53,9 @@ object AgentGuest {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
main.post { steamKeyboard = false }
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) {
|
||||
val showing = snapshot.optBoolean("steamKeyboard")
|
||||
synchronized(lock) {
|
||||
if (source !== observer) return
|
||||
main.post { steamKeyboard = showing }
|
||||
}
|
||||
SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
private fun recordFocus(snapshot: JSONObject) {
|
||||
val fields = linkedMapOf<String, Any?>(
|
||||
"focusedApp" to snapshot.opt("focusedApp"),
|
||||
|
||||
@@ -19,10 +19,7 @@ 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>,
|
||||
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
|
||||
) {
|
||||
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
|
||||
sealed interface State {
|
||||
data object Missing : State
|
||||
data class Downloading(val progress: Float) : State
|
||||
@@ -43,20 +40,22 @@ internal class AddonPack(
|
||||
|
||||
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), version(context))
|
||||
fun dir(context: Context) = File(root(context), sha256)
|
||||
fun file(context: Context, relative: String) = File(dir(context), relative)
|
||||
|
||||
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
|
||||
fun installed(context: Context): Boolean = available && 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()
|
||||
fun outdated(context: Context): Boolean = !installed(context) &&
|
||||
root(context).listFiles().orEmpty().any { it.name != sha256 && File(it, COMPLETE).isFile }
|
||||
|
||||
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 }
|
||||
fun remove(context: Context): Boolean {
|
||||
synchronized(lock) {
|
||||
if (mutableState.value is State.Downloading) return false
|
||||
root(context).deleteRecursively()
|
||||
mutableState.value = State.Missing
|
||||
}
|
||||
.maxByOrNull { it.lastModified() }?.name
|
||||
return !root(context).exists()
|
||||
}
|
||||
|
||||
fun refresh(context: Context): State {
|
||||
val now = mutableState.value
|
||||
@@ -99,7 +98,7 @@ internal class AddonPack(
|
||||
|
||||
private fun install(context: Context): Boolean {
|
||||
if (!available) {
|
||||
Log.w(TAG, if (reuseDeviceCopy) "$name: this build has no pack and none is installed" else "$name: this build has no pack")
|
||||
Log.w(TAG, "$name: this build has no pack")
|
||||
return false
|
||||
}
|
||||
val root = root(context).apply { mkdirs() }
|
||||
@@ -139,9 +138,11 @@ internal class AddonPack(
|
||||
@JvmField
|
||||
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
|
||||
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
|
||||
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
|
||||
val VR = AddonPack("vr", BuildConfig.PACK_VR_SHA256, BuildConfig.PACK_VR_SIZE, listOf(
|
||||
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
|
||||
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
|
||||
"libopencv_java4.so", "libmediapipe_tasks_jni.so", "luma-ultra-640x480.json",
|
||||
"droiddeck/bin/linuxarm64/driver_droiddeck.so", "layer/libVkLayer_droiddeck_vr.so",
|
||||
"layer/VK_LAYER_DROIDDECK_vr_implicit.json"))
|
||||
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
|
||||
listOf("version", "xodus-cli", "xodus-service"))
|
||||
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
|
||||
|
||||
@@ -29,7 +29,6 @@ object GameEnvironment {
|
||||
|
||||
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"DROIDDECK_ANTICHEAT_OFFLINE" to "1",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
"DOTNET_GCRegionRange" to "800000000",
|
||||
|
||||
@@ -32,7 +32,6 @@ object GameEnvironmentOptions {
|
||||
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
|
||||
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
|
||||
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
|
||||
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
|
||||
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
|
||||
|
||||
@@ -146,21 +146,16 @@ object LogRedactor {
|
||||
return out
|
||||
}
|
||||
|
||||
/** The at-rest rewrite reads a file into memory twice; larger logs are only scrubbed on the way out. */
|
||||
const val IN_MEMORY_SCRUB_MAX = 64L * 1024 * 1024
|
||||
|
||||
fun isBinary(file: java.io.File): Boolean = try {
|
||||
file.inputStream().use { input ->
|
||||
fun isText(file: java.io.File): Boolean = try {
|
||||
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
|
||||
val buf = ByteArray(4096)
|
||||
val n = input.read(buf)
|
||||
n > 0 && (0 until n).any { buf[it].toInt() == 0 }
|
||||
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
true
|
||||
false
|
||||
}
|
||||
|
||||
fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file)
|
||||
|
||||
const val RULES_VERSION = 3
|
||||
|
||||
private val LAN_IPV4 = Regex(
|
||||
@@ -181,62 +176,8 @@ object LogRedactor {
|
||||
}
|
||||
}
|
||||
|
||||
private const val SHARE_LINE_MAX = 64 * 1024
|
||||
|
||||
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
|
||||
src.inputStream().buffered().use { input ->
|
||||
input.mark(2)
|
||||
val first = input.read()
|
||||
val second = input.read()
|
||||
input.reset()
|
||||
if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) {
|
||||
out.write("<unsupported encoding; file withheld>\n")
|
||||
return
|
||||
}
|
||||
val decoder = Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
|
||||
java.io.InputStreamReader(input, decoder).use { reader ->
|
||||
val buffer = CharArray(8192)
|
||||
val line = StringBuilder()
|
||||
var withheld = false
|
||||
var afterCr = false
|
||||
fun emit() {
|
||||
out.write(if (withheld) "<unsupported or oversized line withheld>" else redactForShare(line.toString()))
|
||||
out.write("\n")
|
||||
line.setLength(0)
|
||||
withheld = false
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
val size = reader.read(buffer)
|
||||
if (size < 0) break
|
||||
for (i in 0 until size) {
|
||||
val ch = buffer[i]
|
||||
if (afterCr) {
|
||||
afterCr = false
|
||||
if (ch == '\n') continue
|
||||
}
|
||||
if (ch == '\r' || ch == '\n') {
|
||||
emit()
|
||||
afterCr = ch == '\r'
|
||||
} else if (!withheld) {
|
||||
// Splitting a line can separate a credential from its label.
|
||||
if (ch == '\u0000' || line.length == SHARE_LINE_MAX) {
|
||||
withheld = true
|
||||
line.setLength(0)
|
||||
} else {
|
||||
line.append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (withheld || line.isNotEmpty()) emit()
|
||||
} catch (_: java.nio.charset.CharacterCodingException) {
|
||||
out.write("<unsupported encoding; file withheld>\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
private fun quoted(value: String): String {
|
||||
|
||||
@@ -85,7 +85,7 @@ class AppFilesProvider : DocumentsProvider() {
|
||||
|
||||
override fun deleteDocument(documentId: String) {
|
||||
val file = fileFor(documentId)
|
||||
if (file.canonicalPath == baseCanonical) throw FileNotFoundException("the app folder itself is not deleted")
|
||||
if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted")
|
||||
try {
|
||||
deleteTree(file.toPath())
|
||||
} catch (e: IOException) {
|
||||
@@ -154,7 +154,7 @@ class AppFilesProvider : DocumentsProvider() {
|
||||
|
||||
private fun fileFor(documentId: String): File {
|
||||
val f = File(documentId)
|
||||
if (!f.isAbsolute || f.normalize().path != documentId || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
|
||||
if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
|
||||
if (!f.exists()) throw FileNotFoundException("$documentId not found")
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
@@ -152,11 +151,8 @@ fun FileManagerScreen(
|
||||
var conflictChoice by remember { mutableStateOf<ConflictChoice?>(null) }
|
||||
var conflictApplyToAll by remember { mutableStateOf(false) }
|
||||
var operationJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var cancelledJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var pendingBulkDelete by remember { mutableStateOf<List<File>>(emptyList()) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
val visibleEntries = if (searchQuery.isBlank()) entries
|
||||
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") }
|
||||
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
|
||||
@@ -178,20 +174,16 @@ fun FileManagerScreen(
|
||||
var showFavorites by remember { mutableStateOf(false) }
|
||||
var favTick by remember { mutableIntStateOf(0) }
|
||||
|
||||
var listingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
|
||||
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
|
||||
currentDir = dir
|
||||
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
|
||||
listingJob?.cancel()
|
||||
listingJob = scope.launch {
|
||||
scope.launch {
|
||||
val list = withContext(Dispatchers.IO) {
|
||||
dir.listFiles()?.toList()
|
||||
?.filter { if (pickDirMode) it.isDirectory else !pickMode || it.isDirectory || matchesPickExt(it) }
|
||||
?.filter { showHidden || !it.name.startsWith(".") }
|
||||
?.sortedWith(comparatorFor(sortBy, sortDesc)) ?: emptyList()
|
||||
}
|
||||
if (currentDir != dir) return@launch
|
||||
entries = list
|
||||
if (resetScroll) listState.scrollToItem(0)
|
||||
}
|
||||
@@ -228,7 +220,7 @@ fun FileManagerScreen(
|
||||
|
||||
fun uniqueDestination(dir: File, name: String): File {
|
||||
var candidate = File(dir, name)
|
||||
if (!java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return candidate
|
||||
if (!candidate.exists()) return candidate
|
||||
val dot = name.lastIndexOf('.')
|
||||
val base = if (dot > 0) name.substring(0, dot) else name
|
||||
val ext = if (dot > 0) name.substring(dot) else ""
|
||||
@@ -236,7 +228,7 @@ fun FileManagerScreen(
|
||||
do {
|
||||
candidate = File(dir, "$base ($i)$ext")
|
||||
i++
|
||||
} while (java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS))
|
||||
} while (candidate.exists())
|
||||
return candidate
|
||||
}
|
||||
|
||||
@@ -260,7 +252,7 @@ fun FileManagerScreen(
|
||||
|
||||
fun performPaste() {
|
||||
val sources = clipboardFiles
|
||||
if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
|
||||
if (sources.isEmpty()) return
|
||||
val dstDir = currentDir
|
||||
val cut = isCutOperation
|
||||
|
||||
@@ -269,7 +261,6 @@ fun FileManagerScreen(
|
||||
operationDeterminate = true
|
||||
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
|
||||
isOperationRunning = true
|
||||
val job = coroutineContext[kotlinx.coroutines.Job]
|
||||
|
||||
var applyToAll: ConflictChoice? = null
|
||||
var failed = 0
|
||||
@@ -287,7 +278,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
var dst = File(dstDir, src.name)
|
||||
if (java.nio.file.Files.exists(dst.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (dst.exists()) {
|
||||
val choice = applyToAll ?: askConflict(src)?.also {
|
||||
if (conflictApplyToAll) applyToAll = it
|
||||
} ?: run { skipped++; null } ?: continue
|
||||
@@ -305,7 +296,6 @@ fun FileManagerScreen(
|
||||
}
|
||||
var lastPct = -1
|
||||
val onProgress = FileOps.ProgressCallback { copied, total ->
|
||||
job?.ensureActive()
|
||||
val pct = if (total > 0) ((copied * 100) / total).toInt() else 100
|
||||
if (pct != lastPct) {
|
||||
lastPct = pct
|
||||
@@ -314,8 +304,8 @@ fun FileManagerScreen(
|
||||
}
|
||||
val target = dst
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false }
|
||||
else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false }
|
||||
if (cut) FileOps.moveWithProgress(src, target, onProgress)
|
||||
else FileOps.copyWithProgress(src, target, onProgress)
|
||||
}
|
||||
if (ok) done++ else failed++
|
||||
}
|
||||
@@ -801,21 +791,19 @@ fun FileManagerScreen(
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val allVisibleSelected = visibleEntries.isNotEmpty() && visibleEntries.all { it.absolutePath in selectedPaths }
|
||||
val anyVisibleSelected = visibleEntries.any { it.absolutePath in selectedPaths }
|
||||
val selBarPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp)
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shown = visibleEntries.map { it.absolutePath }
|
||||
selectedPaths = if (allVisibleSelected) selectedPaths - shown.toSet() else selectedPaths + shown
|
||||
selectedPaths = if (selectedPaths.size == entries.size) emptySet()
|
||||
else entries.map { it.absolutePath }.toSet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text(if (allVisibleSelected) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
|
||||
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = anyVisibleSelected,
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
onClick = {
|
||||
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
|
||||
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
|
||||
isCutOperation = false
|
||||
selectionMode = false
|
||||
selectedPaths = emptySet()
|
||||
@@ -824,9 +812,9 @@ fun FileManagerScreen(
|
||||
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = anyVisibleSelected,
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
onClick = {
|
||||
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
|
||||
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
|
||||
isCutOperation = true
|
||||
selectionMode = false
|
||||
selectedPaths = emptySet()
|
||||
@@ -835,8 +823,8 @@ fun FileManagerScreen(
|
||||
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = anyVisibleSelected,
|
||||
onClick = { pendingBulkDelete = visibleEntries.filter { it.absolutePath in selectedPaths } },
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
|
||||
contentPadding = selBarPadding,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
|
||||
@@ -854,7 +842,7 @@ fun FileManagerScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f))
|
||||
.clickable(enabled = !isOperationRunning) { performPaste() }
|
||||
.clickable { performPaste() }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
@@ -890,7 +878,6 @@ fun FileManagerScreen(
|
||||
)
|
||||
if (operationJob != null) {
|
||||
TextButton(onClick = {
|
||||
cancelledJob = operationJob
|
||||
operationJob?.cancel()
|
||||
operationJob = null
|
||||
isOperationRunning = false
|
||||
@@ -977,7 +964,8 @@ fun FileManagerScreen(
|
||||
.fillMaxSize()
|
||||
.nestedScroll(pullState.nestedScrollConnection),
|
||||
) {
|
||||
val shownEntries = visibleEntries
|
||||
val shownEntries = if (searchQuery.isBlank()) entries
|
||||
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
|
||||
|
||||
if (showGrid && entries.isNotEmpty()) {
|
||||
LazyVerticalGrid(
|
||||
|
||||
@@ -45,26 +45,23 @@ object FileOps {
|
||||
return f.length()
|
||||
}
|
||||
|
||||
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
|
||||
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
|
||||
if (sameFile(src, dst)) return true
|
||||
if (isCancelled()) return false
|
||||
if (!dst.exists() && src.renameTo(dst)) {
|
||||
progress?.onProgress(1, 1)
|
||||
return true
|
||||
}
|
||||
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
|
||||
if (isCancelled()) return false
|
||||
if (!copyWithProgress(src, dst, progress)) return false
|
||||
return delete(src)
|
||||
}
|
||||
|
||||
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
|
||||
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
|
||||
val total = maxOf(totalSize(src), 1L)
|
||||
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
|
||||
return copyWithProgress(src, dst, longArrayOf(0), total, progress)
|
||||
}
|
||||
|
||||
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean {
|
||||
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
|
||||
if (isSymlink(src)) return copyLink(src, dst)
|
||||
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean {
|
||||
if (isSymlink(src)) return true
|
||||
if (sameFile(src, dst)) return true
|
||||
if (src.isDirectory && isWithin(dst, src)) {
|
||||
Log.e(TAG, "Refusing to copy directory into itself: $src -> $dst")
|
||||
@@ -74,7 +71,7 @@ object FileOps {
|
||||
if (!dst.exists() && !dst.mkdirs()) return false
|
||||
var allOk = true
|
||||
src.list()?.forEach { name ->
|
||||
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
|
||||
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress)) {
|
||||
Log.e(TAG, "Failed to copy: ${File(src, name)}")
|
||||
allOk = false
|
||||
}
|
||||
@@ -84,41 +81,13 @@ object FileOps {
|
||||
val parent = dst.parentFile
|
||||
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
|
||||
val chunk = 4L * 1024 * 1024
|
||||
var staging: File? = null
|
||||
var created = false
|
||||
var output: java.nio.channels.FileChannel? = null
|
||||
try {
|
||||
output = try {
|
||||
java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW)
|
||||
.also { created = true }
|
||||
} catch (e: java.nio.file.FileAlreadyExistsException) {
|
||||
null
|
||||
}
|
||||
val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst
|
||||
var permissions: Set<java.nio.file.attribute.PosixFilePermission>? = null
|
||||
if (output == null) {
|
||||
if (isSymlink(target) || target.parentFile?.isDirectory != true) {
|
||||
throw IOException("Destination symlink target is not a writable directory entry: $dst")
|
||||
}
|
||||
if (target.isDirectory) throw IOException("Destination is a directory: $target")
|
||||
permissions = try {
|
||||
Files.getPosixFilePermissions(target.toPath())
|
||||
} catch (e: java.nio.file.NoSuchFileException) {
|
||||
null
|
||||
} catch (e: UnsupportedOperationException) {
|
||||
null
|
||||
}
|
||||
if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target")
|
||||
val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-")
|
||||
staging = directory.toFile()
|
||||
output = FileOutputStream(File(staging, "file")).channel
|
||||
}
|
||||
FileInputStream(src).channel.use { input ->
|
||||
checkNotNull(output).use { channel ->
|
||||
FileOutputStream(dst).channel.use { output ->
|
||||
val size = input.size()
|
||||
var position = 0L
|
||||
while (position < size) {
|
||||
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
|
||||
val transferred = input.transferTo(position, minOf(chunk, size - position), output)
|
||||
if (transferred <= 0) break
|
||||
position += transferred
|
||||
done[0] += transferred
|
||||
@@ -127,54 +96,11 @@ object FileOps {
|
||||
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
|
||||
}
|
||||
}
|
||||
staging?.let { directory ->
|
||||
val copied = File(directory, "file").toPath()
|
||||
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
|
||||
Files.setPosixFilePermissions(copied, permissions)
|
||||
}
|
||||
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
|
||||
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
return true
|
||||
} catch (e: java.util.concurrent.CancellationException) {
|
||||
if (created) dst.delete()
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Failed to copy file: $src to $dst", e)
|
||||
if (created) dst.delete()
|
||||
dst.delete()
|
||||
return false
|
||||
} finally {
|
||||
output?.close()
|
||||
staging?.let { directory ->
|
||||
File(directory, "file").delete()
|
||||
directory.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyLink(src: File, dst: File): Boolean {
|
||||
if (src.absoluteFile == dst.absoluteFile) return true
|
||||
return try {
|
||||
val target = Files.readSymbolicLink(src.toPath())
|
||||
val parent = dst.parentFile
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) return false
|
||||
try {
|
||||
Files.createSymbolicLink(dst.toPath(), target)
|
||||
} catch (e: java.nio.file.FileAlreadyExistsException) {
|
||||
val staging = Files.createTempDirectory(checkNotNull(dst.absoluteFile.parentFile).toPath(), ".droiddeck-link-")
|
||||
val tmp = staging.resolve("link")
|
||||
try {
|
||||
Files.createSymbolicLink(tmp, target)
|
||||
Files.move(tmp, dst.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
|
||||
} finally {
|
||||
Files.deleteIfExists(tmp)
|
||||
Files.deleteIfExists(staging)
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to copy link: $src to $dst", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,20 +41,10 @@ object AddedGames {
|
||||
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
|
||||
}
|
||||
|
||||
private val SKIP_PREFIX = Regex("(?i)^(unins|setup|vc_?redist|dxsetup|dxwebsetup|dotnet|directx|easyanticheat|battleye).*")
|
||||
private val SKIP_PART = Regex("(?i)redist|prereq|installer|uninstall|crashreport|crashhandler|crashpad|crashsender|launchhelper")
|
||||
private val SKIP_WORDS = setOf(
|
||||
"eac", "report", "reporter", "editor", "server", "benchmark", "helper",
|
||||
"update", "updater", "config", "configure", "configurator", "configuration", "settings",
|
||||
private val SKIP = Regex(
|
||||
"(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" +
|
||||
"|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$",
|
||||
)
|
||||
private val WORD = Regex("[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+")
|
||||
|
||||
internal fun skipExe(name: String): Boolean {
|
||||
if (!name.endsWith(".exe", ignoreCase = true)) return false
|
||||
val base = name.dropLast(4)
|
||||
if (SKIP_PREFIX.matches(base) || SKIP_PART.containsMatchIn(base)) return true
|
||||
return WORD.findAll(base).any { it.value.lowercase() in SKIP_WORDS }
|
||||
}
|
||||
|
||||
const val GUEST_DIR = "/root/Games"
|
||||
|
||||
@@ -85,7 +75,7 @@ object AddedGames {
|
||||
val key = "${folder.path}|${folder.lastModified()}"
|
||||
val now = System.currentTimeMillis()
|
||||
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
|
||||
val guessed = GameExePicker.rank(folder) { !skipExe(it.name) }
|
||||
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
|
||||
val declared = declaredExes(folder)
|
||||
val preferred = declared.map { it.path }.toSet()
|
||||
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
|
||||
@@ -138,7 +128,7 @@ object AddedGames {
|
||||
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
|
||||
return names.asSequence()
|
||||
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
|
||||
.filter { it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) && it.isFile }
|
||||
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
|
||||
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
|
||||
.distinctBy { it.path }
|
||||
.toList()
|
||||
|
||||
@@ -14,7 +14,6 @@ import java.io.InputStream
|
||||
object GameFiles {
|
||||
const val EXTENSION = "steam"
|
||||
private const val MAX_BYTES = 128
|
||||
private const val MAX_NAME_BYTES = 200
|
||||
private const val INVENTORY = ".droiddeck-sync.json"
|
||||
private const val FORMAT = "droiddeck-game-files-v1"
|
||||
|
||||
@@ -51,15 +50,8 @@ object GameFiles {
|
||||
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
|
||||
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
|
||||
.trim().trim('.').ifBlank { "Game" }
|
||||
val points = sanitized.codePoints().toArray()
|
||||
var end = 0
|
||||
var bytes = 0
|
||||
while (end < points.size) {
|
||||
bytes += String(points, end, 1).toByteArray().size
|
||||
if (bytes > MAX_NAME_BYTES) break
|
||||
end++
|
||||
}
|
||||
val title = String(points, 0, end).trimEnd { it.isWhitespace() || it == '.' }.ifBlank { "Game" }
|
||||
val points = sanitized.codePoints().limit(40).toArray()
|
||||
val title = String(points, 0, points.size)
|
||||
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
|
||||
return "$title$suffix.$EXTENSION"
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ object GameIdentifier {
|
||||
}
|
||||
|
||||
private val JUNK_NAME_RE = Regex(
|
||||
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*|bootstrappackagedgame)$"""
|
||||
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
|
||||
)
|
||||
|
||||
fun isJunkPeName(name: String): Boolean {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import java.io.File
|
||||
|
||||
object GameRemoval {
|
||||
enum class Kind { STORE, SHORTCUT, LAUNCHER }
|
||||
|
||||
fun kind(game: Library.SteamGame): Kind = when {
|
||||
game.library == Library.EPIC_LAUNCHER -> Kind.LAUNCHER
|
||||
game.library == Library.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT
|
||||
else -> Kind.STORE
|
||||
}
|
||||
|
||||
class SteamFiles(val dir: File, val manifest: File, val partial: File?)
|
||||
|
||||
fun steamFiles(game: Library.SteamGame): SteamFiles? {
|
||||
val dir = game.gameFiles ?: return null
|
||||
val common = dir.parentFile ?: return null
|
||||
if (common.name != "common") return null
|
||||
val steamapps = common.parentFile ?: return null
|
||||
if (steamapps.name != "steamapps") return null
|
||||
val manifest = File(steamapps, "appmanifest_${game.appId}.acf")
|
||||
if (!manifest.isFile) return null
|
||||
if (!declares(manifest, game.appId)) return null
|
||||
return SteamFiles(dir, manifest, File(steamapps, "downloading/${game.appId}"))
|
||||
}
|
||||
|
||||
fun delete(target: SteamFiles): Boolean {
|
||||
StoreInstalls.deleteTree(target.dir)
|
||||
if (target.dir.exists()) return false
|
||||
target.partial?.let { StoreInstalls.deleteTree(it) }
|
||||
target.manifest.delete()
|
||||
return !target.manifest.exists()
|
||||
}
|
||||
|
||||
fun storeGame(game: Library.SteamGame): InstalledStoreGame? {
|
||||
val folder = game.gameFiles ?: return null
|
||||
val store = Store.byId(game.source)
|
||||
val id = game.storeId
|
||||
if (store != null && id != null) StoresState.installedGame(store, id)?.let { return it }
|
||||
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled } ?: return null
|
||||
return InstalledStoreGame(sidecar, folder)
|
||||
}
|
||||
|
||||
private fun declares(manifest: File, appId: Int): Boolean =
|
||||
runCatching { manifest.readText() }.getOrNull()?.let { Regex("\"appid\"\\s+\"$appId\"").containsMatchIn(it) } == true
|
||||
}
|
||||
@@ -50,7 +50,6 @@ 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,
|
||||
@@ -614,13 +613,12 @@ 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 || m is RokidModel,
|
||||
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel,
|
||||
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,
|
||||
@@ -636,8 +634,7 @@ object Glasses {
|
||||
scheduleModeCheck()
|
||||
}
|
||||
|
||||
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) ||
|
||||
vid == XrealModel.VENDOR_ID || vid == RokidModel.VENDOR_ID
|
||||
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID
|
||||
|
||||
internal fun supports30Hz(display: Display): Boolean {
|
||||
val current = display.mode
|
||||
@@ -657,13 +654,11 @@ object Glasses {
|
||||
?: VitureProtocol.model(device.productId, name)
|
||||
}
|
||||
else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel
|
||||
else RokidModel.model(device.vendorId, device.productId, name)
|
||||
?: RayNeoModel.model(device.vendorId, device.productId, name)
|
||||
else RayNeoModel.model(device.vendorId, device.productId, name)
|
||||
}
|
||||
|
||||
internal fun isGlassesDisplayName(name: String) =
|
||||
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) ||
|
||||
RokidModel.isDisplay(name)
|
||||
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name)
|
||||
|
||||
private fun findDisplay(usbPresent: Boolean): Display? {
|
||||
return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent)
|
||||
@@ -728,8 +723,6 @@ 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)
|
||||
@@ -797,33 +790,6 @@ 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,7 +8,6 @@ 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) { if (state.sbs90) add(90) }
|
||||
else addAll(state.rates2d.filter { it > 60 })
|
||||
if (!state.sbs || state.sbs90) add(90)
|
||||
if (!state.sbs) add(120)
|
||||
}
|
||||
|
||||
fun outputHz(settings: GlassesSettings, state: GlassesState): Int {
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import com.droiddeck.launcher.handtracking.TrackingPack
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
|
||||
internal enum class HandModel(val file: String, val sha256: String) {
|
||||
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
|
||||
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
|
||||
FULL(VrPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
|
||||
LITE(VrPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
|
||||
|
||||
companion object {
|
||||
val DEFAULT = FULL
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ internal data class StereoCalibrationProfile(
|
||||
val verified: Boolean, val bundledBase: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
const val BASE_ASSET = "calibration/luma-ultra-640x480.json"
|
||||
const val BASE_IDENTITY = "luma-ultra-base-v1"
|
||||
|
||||
fun select(saved: String?, base: () -> String?, identity: String?, width: Int, height: Int): StereoCalibrationProfile? {
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.glasses
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.handtracking.TrackingPack
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
@@ -171,7 +171,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
|
||||
} }
|
||||
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
|
||||
val bytes=TrackingPack.file(context,selected.file).readBytes()
|
||||
val bytes=VrPack.file(context,selected.file).readBytes()
|
||||
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
|
||||
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
|
||||
@@ -326,11 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
|
||||
}
|
||||
private fun loop() {
|
||||
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
|
||||
val loaded=VrPack.awaitNative(context,{ running }) { state ->
|
||||
if(state is AddonPack.State.Downloading)
|
||||
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
|
||||
message=context.getString(R.string.setup_runtime_progress,context.getString(R.string.vr_module_downloading),(state.progress*100).toInt())
|
||||
}
|
||||
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
|
||||
if(!loaded) { if(running) message=context.getString(R.string.vr_module_failed);return }
|
||||
var grayL=Mat();var grayR=Mat();val rgba=Mat()
|
||||
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
|
||||
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
|
||||
@@ -341,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
|
||||
var rate=0f
|
||||
try {
|
||||
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
|
||||
check(VrPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
|
||||
joyConFlow=StereoJoyConFlow()
|
||||
while(running) {
|
||||
while(true) {
|
||||
@@ -395,7 +395,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
calibration?.close()
|
||||
val selected=StereoCalibrationProfile.select(
|
||||
if(identity!=null) runCatching { profile.readText() }.getOrNull() else null,
|
||||
{ runCatching { context.assets.open(StereoCalibrationProfile.BASE_ASSET).bufferedReader().use { it.readText() } }.getOrNull() },
|
||||
{ runCatching { VrPack.file(context,VrPack.LUMA_CALIBRATION).readText() }.getOrNull() },
|
||||
identity,w,h)
|
||||
calibration=selected?.let(StereoCalibration::fromProfile)
|
||||
calibrator?.close();calibrator=null
|
||||
|
||||
@@ -4,27 +4,20 @@ 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(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
|
||||
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)
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
@@ -186,8 +187,8 @@ internal class HandCamera(
|
||||
private fun prepare() {
|
||||
if (stopped) return
|
||||
runCatching {
|
||||
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
|
||||
val model = TrackingPack.model(context, TrackingPack.POSE)
|
||||
check(VrPack.loadNative(context)) { "the VR pack is not installed" }
|
||||
val model = VrPack.model(context, VrPack.POSE)
|
||||
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
|
||||
.setRunningMode(RunningMode.VIDEO)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
@@ -150,9 +151,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
|
||||
private const val MIN_CONFIDENCE = 0.4f
|
||||
|
||||
fun open(context: Context): HandModel {
|
||||
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
|
||||
check(VrPack.loadNative(context)) { "the VR pack is not installed" }
|
||||
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
|
||||
val model = TrackingPack.model(context, TrackingPack.HAND)
|
||||
val model = VrPack.model(context, VrPack.HAND)
|
||||
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
|
||||
.setRunningMode(mode)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
@@ -40,11 +41,11 @@ internal object HandTracking {
|
||||
side = prefs.getString(SIDE, null),
|
||||
permitted = permitted(context),
|
||||
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
|
||||
downloaded = TrackingPack.installed(context),
|
||||
downloaded = VrPack.installed(context),
|
||||
)
|
||||
state.value = next
|
||||
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
|
||||
TrackingPack.ensure(context)
|
||||
if (next.enabled && next.usable && !next.downloaded && VrPack.refresh(context) == AddonPack.State.Missing)
|
||||
VrPack.ensure(context)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
|
||||
internal object TrackingPack {
|
||||
const val HAND = "hand_landmarker.task"
|
||||
const val HAND_LITE = "hand_landmarker_lite.task"
|
||||
const val POSE = "pose_landmarker_lite.task"
|
||||
private const val OPENCV = "libopencv_java4.so"
|
||||
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
|
||||
private const val TAG = "TrackingPack"
|
||||
|
||||
private val pack = AddonPack.TRACKING
|
||||
val state get() = pack.state
|
||||
val size get() = pack.size
|
||||
private val lock = Any()
|
||||
@Volatile private var nativeLoaded = false
|
||||
|
||||
fun installed(context: Context) = pack.installed(context)
|
||||
fun refresh(context: Context) = pack.refresh(context)
|
||||
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
|
||||
|
||||
fun file(context: Context, name: String): File = pack.file(context, name)
|
||||
|
||||
fun model(context: Context, name: String): ByteBuffer =
|
||||
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
|
||||
|
||||
/**
|
||||
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
|
||||
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
|
||||
*/
|
||||
fun loadNative(context: Context): Boolean {
|
||||
if (nativeLoaded) return true
|
||||
synchronized(lock) {
|
||||
if (nativeLoaded) return true
|
||||
if (!pack.installed(context)) return false
|
||||
return runCatching {
|
||||
System.load(file(context, OPENCV).absolutePath)
|
||||
System.load(file(context, MEDIAPIPE).absolutePath)
|
||||
nativeLoaded = true
|
||||
true
|
||||
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
|
||||
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
|
||||
pack.await(context, running, onState) && loadNative(context)
|
||||
}
|
||||
@@ -146,15 +146,9 @@ public final class PadBridge {
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isSystemVirtualInputName(String name) {
|
||||
if (name == null) return false;
|
||||
String lower = name.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return lower.startsWith("uinput-") || lower.startsWith("uinput_");
|
||||
}
|
||||
|
||||
public static boolean isFromController(InputDevice device) {
|
||||
if (device == null) return false;
|
||||
if (device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
|
||||
if (device.isVirtual()) return false;
|
||||
int sources = device.getSources();
|
||||
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
|
||||
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
|
||||
@@ -171,7 +165,7 @@ public final class PadBridge {
|
||||
|
||||
public static boolean isControllerKeyEvent(KeyEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
|
||||
if (device == null || device.isVirtual()) return false;
|
||||
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
|
||||
@@ -196,7 +190,6 @@ public final class PadBridge {
|
||||
public static boolean isControllerMotionEvent(MotionEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
|
||||
&& !isSystemVirtualInputName(device.getName())
|
||||
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
|
||||
}
|
||||
@@ -326,27 +319,16 @@ public final class PadBridge {
|
||||
boolean wired = wiredXboxLayout(event);
|
||||
pad.rightX = rightX(event, wired);
|
||||
pad.rightY = rightY(event, wired);
|
||||
int source = event.getSource();
|
||||
float lt = 0f;
|
||||
float rt = 0f;
|
||||
if (wired || hasAxis(device, source, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)) {
|
||||
lt = leftTrigger(event, wired);
|
||||
pad.leftTrigger = lt;
|
||||
}
|
||||
if (wired || hasAxis(device, source, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
|
||||
rt = rightTrigger(event, wired);
|
||||
pad.rightTrigger = rt;
|
||||
}
|
||||
if (hasAxis(device, source, MotionEvent.AXIS_HAT_X)) {
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
pad.right = hatX > 0.5f;
|
||||
pad.left = hatX < -0.5f;
|
||||
}
|
||||
if (hasAxis(device, source, MotionEvent.AXIS_HAT_Y)) {
|
||||
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
|
||||
pad.up = hatY < -0.5f;
|
||||
pad.down = hatY > 0.5f;
|
||||
}
|
||||
float lt = leftTrigger(event, wired);
|
||||
float rt = rightTrigger(event, wired);
|
||||
pad.leftTrigger = lt;
|
||||
pad.rightTrigger = rt;
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
|
||||
pad.up = hatY < -0.5f;
|
||||
pad.right = hatX > 0.5f;
|
||||
pad.down = hatY > 0.5f;
|
||||
pad.left = hatX < -0.5f;
|
||||
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|
||||
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
|
||||
notePlayerInput();
|
||||
@@ -355,12 +337,6 @@ public final class PadBridge {
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean hasAxis(InputDevice device, int source, int... axes) {
|
||||
if (device == null) return false;
|
||||
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public interface TouchRoute {
|
||||
boolean active();
|
||||
|
||||
|
||||
@@ -16,13 +16,10 @@ object SteamTouchBindings {
|
||||
const val DEFAULT_FOREGROUND = "#232323"
|
||||
const val DEFAULT_BACKGROUND = "#E4E4E4"
|
||||
|
||||
fun cleanLabel(raw: String): String =
|
||||
raw.replace(Regex("""["\\,\p{Cntrl}]"""), " ").replace(Regex("""\s+"""), " ").trim().let { it.take(it.offsetByCodePoints(0, minOf(32, it.codePointCount(0, it.length)))) }
|
||||
|
||||
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
|
||||
fun compose(): String =
|
||||
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, ${cleanLabel(label)}, "
|
||||
else "$action, ${cleanLabel(label)}, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
|
||||
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
|
||||
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
|
||||
|
||||
val hasIcon get() = icon.isNotEmpty()
|
||||
}
|
||||
|
||||
@@ -84,11 +84,11 @@ class SteamTouchIconPicker(
|
||||
updatePreview(preview)
|
||||
showPanel(host, title, root, listOf(
|
||||
context.getString(R.string.touch_no_icon) to {
|
||||
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = "", foreground = "", background = ""))
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
|
||||
},
|
||||
context.getString(R.string.common_cancel) to {},
|
||||
context.getString(R.string.common_use) to {
|
||||
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = icon,
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
|
||||
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
|
||||
},
|
||||
)) { loader.shutdown() }
|
||||
|
||||
@@ -22,7 +22,6 @@ 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",
|
||||
@@ -33,34 +32,7 @@ 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>, 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()
|
||||
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
|
||||
|
||||
fun installed(context: Context): String? {
|
||||
val loader = loader(context)
|
||||
@@ -102,13 +74,17 @@ 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
|
||||
@@ -129,11 +105,9 @@ 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 (!row.tag.startsWith("v")) continue
|
||||
all.add(row)
|
||||
(if (isPrerelease || "-pre" in row.tag) prerelease else stable).add(row)
|
||||
(if (isPrerelease) prerelease else stable).add(row)
|
||||
}
|
||||
ReleaseChannels(stable, prerelease, all)
|
||||
ReleaseChannels(stable, prerelease)
|
||||
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.handtracking.HandTracking
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** The SteamVR driver and layer, hand-tracking models, OpenCV/MediaPipe native libraries and Luma calibration, downloaded on first use. */
|
||||
internal object VrPack {
|
||||
data class Removal(val busy: Boolean = false, val error: String? = null)
|
||||
|
||||
const val HAND = "hand_landmarker.task"
|
||||
const val HAND_LITE = "hand_landmarker_lite.task"
|
||||
const val POSE = "pose_landmarker_lite.task"
|
||||
const val LUMA_CALIBRATION = "luma-ultra-640x480.json"
|
||||
private const val OPENCV = "libopencv_java4.so"
|
||||
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
|
||||
private const val GUEST = "usr/local/lib/droiddeck-vr"
|
||||
private const val IMPLICIT_LAYER = "usr/share/vulkan/implicit_layer.d/droiddeck_vr.json"
|
||||
private const val PACKED_LAYER = "layer/VK_LAYER_DROIDDECK_vr_implicit.json"
|
||||
private const val MARKER = ".pack"
|
||||
private const val LEGACY = "addons/tracking"
|
||||
private const val TAG = "VrPack"
|
||||
private val TREES = listOf("droiddeck", "layer")
|
||||
private val IDLE = setOf(SessionPhase.IDLE, SessionPhase.FAILED)
|
||||
|
||||
private val pack = AddonPack.VR
|
||||
val state get() = pack.state
|
||||
val size get() = pack.size
|
||||
private val lock = Any()
|
||||
private val guest = Any()
|
||||
@Volatile private var nativeLoaded = false
|
||||
private val worker = Executors.newSingleThreadExecutor { Thread(it, "vr-pack").apply { isDaemon = true } }
|
||||
private val mutableRemoval = MutableStateFlow(Removal())
|
||||
val removal: StateFlow<Removal> = mutableRemoval.asStateFlow()
|
||||
|
||||
fun init(context: Context) {
|
||||
val app = context.applicationContext
|
||||
pack.refresh(app)
|
||||
val legacy = File(app.filesDir, LEGACY)
|
||||
val upgrade = pack.outdated(app) || legacy.listFiles().orEmpty().any { File(it, AddonPack.COMPLETE).isFile }
|
||||
if (legacy.exists()) worker.execute { legacy.deleteRecursively() }
|
||||
if (upgrade) ensure(app)
|
||||
}
|
||||
|
||||
fun installed(context: Context) = pack.installed(context)
|
||||
fun refresh(context: Context) = pack.refresh(context)
|
||||
fun ensure(context: Context) = pack.ensure(context) { app ->
|
||||
HandTracking.load(app)
|
||||
if (SessionState.running || SessionState.phase !in IDLE) stageGuest(app, LinuxRuntime.rootDir(app))
|
||||
}
|
||||
|
||||
fun await(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
|
||||
pack.await(context, running, onState)
|
||||
|
||||
fun file(context: Context, name: String): File = pack.file(context, name)
|
||||
|
||||
fun installedBytes(context: Context): Long = pack.dir(context).walkBottomUp().filter { it.isFile }.sumOf { it.length() }
|
||||
|
||||
fun model(context: Context, name: String): ByteBuffer =
|
||||
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
|
||||
|
||||
/**
|
||||
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
|
||||
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
|
||||
*/
|
||||
fun loadNative(context: Context): Boolean {
|
||||
if (nativeLoaded) return true
|
||||
synchronized(lock) {
|
||||
if (nativeLoaded) return true
|
||||
if (!pack.installed(context)) return false
|
||||
return runCatching {
|
||||
System.load(file(context, OPENCV).absolutePath)
|
||||
System.load(file(context, MEDIAPIPE).absolutePath)
|
||||
nativeLoaded = true
|
||||
true
|
||||
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
|
||||
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
|
||||
pack.await(context, running, onState) && loadNative(context)
|
||||
|
||||
fun stageGuest(context: Context, root: File) = synchronized(guest) {
|
||||
val tree = File(root, GUEST)
|
||||
val layer = File(root, IMPLICIT_LAYER)
|
||||
if (!pack.installed(context)) {
|
||||
FileUtils.delete(tree)
|
||||
FileUtils.delete(layer)
|
||||
return@synchronized
|
||||
}
|
||||
val dir = pack.dir(context)
|
||||
if (FileUtils.readString(File(tree, MARKER)) == dir.name && layer.isFile) return@synchronized
|
||||
runCatching {
|
||||
FileUtils.delete(tree)
|
||||
TREES.forEach { copy(File(dir, it), File(tree, it)) }
|
||||
copy(File(dir, PACKED_LAYER), layer)
|
||||
check(FileUtils.writeString(File(tree, MARKER), dir.name))
|
||||
}.onFailure { Log.w(TAG, "could not stage the VR pack", it) }
|
||||
}
|
||||
|
||||
fun uninstall(context: Context) {
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
mutableRemoval.value = Removal(busy = true)
|
||||
val maintenance = LinuxRuntimeInstaller.beginMaintenance(app, app.getString(R.string.vr_module_removing))
|
||||
val error = when {
|
||||
maintenance == null -> app.getString(R.string.vr_module_busy)
|
||||
maintenance.run(null) { remove(app) } -> null
|
||||
else -> LinuxRuntimeInstaller.removalError() ?: app.getString(R.string.vr_module_remove_failed)
|
||||
}
|
||||
if (error == null) HandTracking.setEnabled(app, false)
|
||||
mutableRemoval.value = Removal(error = error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun remove(context: Context): String? = synchronized(guest) {
|
||||
if (!pack.remove(context)) return@synchronized context.getString(R.string.vr_module_remove_failed)
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
FileUtils.delete(File(root, GUEST))
|
||||
FileUtils.delete(File(root, IMPLICIT_LAYER))
|
||||
null
|
||||
}
|
||||
|
||||
private fun copy(source: File, target: File) {
|
||||
if (source.isDirectory) {
|
||||
source.listFiles().orEmpty().forEach { copy(it, File(target, it.name)) }
|
||||
return
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
val staged = File(target.path + ".staged")
|
||||
source.inputStream().use { input -> staged.outputStream().use { FileUtils.copy(input, it) } }
|
||||
staged.setExecutable(true, false)
|
||||
check(staged.renameTo(target)) { "could not stage ${target.name}" }
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.os.FileObserver
|
||||
import android.os.SystemClock
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import java.io.File
|
||||
|
||||
class InputSysfsComponent(
|
||||
private val nodes: File,
|
||||
base: File,
|
||||
private val udevData: File,
|
||||
private val deckPad: Boolean,
|
||||
private val steamVirtual: Boolean,
|
||||
) : SessionPart() {
|
||||
private val devices = File(base, "devices")
|
||||
private val classDir = File(base, "class")
|
||||
private val shown = HashSet<Int>()
|
||||
private var observer: FileObserver? = null
|
||||
private var running = false
|
||||
|
||||
fun prepare(): Boolean = try {
|
||||
FileUtils.clear(devices)
|
||||
FileUtils.clear(classDir)
|
||||
devices.mkdirs()
|
||||
classDir.mkdirs()
|
||||
udevData.mkdirs()
|
||||
udevData.listFiles()?.forEach { if (isPadRecord(it.name)) it.delete() }
|
||||
devices.isDirectory && classDir.isDirectory
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "pads: could not prepare the input sysfs: $e")
|
||||
false
|
||||
}
|
||||
|
||||
fun binds() = listOf(devices.path + ":" + GUEST_DEVICES, classDir.path + ":" + GUEST_CLASS)
|
||||
|
||||
override fun start() {
|
||||
synchronized(this) { running = true }
|
||||
active = this
|
||||
@Suppress("DEPRECATION")
|
||||
observer = object : FileObserver(nodes.path, CREATE or DELETE or MOVED_TO or MOVED_FROM or CLOSE_WRITE) {
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
if (path != null && NODE_FILE.matches(path)) sync()
|
||||
}
|
||||
}.also { it.startWatching() }
|
||||
sync()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
if (active === this) active = null
|
||||
synchronized(this) {
|
||||
running = false
|
||||
for (node in shown.toList()) hide(node)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sync() {
|
||||
if (!running) return
|
||||
val wanted = (0 until NODE_LIMIT).filter { node ->
|
||||
File(nodes, "event$node").exists() && if (node >= FIRST_VIRTUAL) File(nodes, ".uinput-event$node.uevent").isFile
|
||||
else !ringWithdrawn(deckPad, padType)
|
||||
}.toSet()
|
||||
for (node in shown - wanted) hide(node)
|
||||
for (node in wanted - shown) show(node)
|
||||
}
|
||||
|
||||
private fun show(node: Int) {
|
||||
val pad = (if (node >= FIRST_VIRTUAL) runCatching { File(nodes, ".uinput-event$node.uevent").readText() }.getOrNull()?.let(::virtualPad)
|
||||
else ringPad(node, steamVirtual)) ?: return
|
||||
try {
|
||||
val input = File(devices, "input$node")
|
||||
val event = File(input, "event$node")
|
||||
event.mkdirs()
|
||||
File(input, "uevent").writeText(pad.uevent())
|
||||
File(input, "name").writeText(pad.name + "\n")
|
||||
File(input, "phys").writeText(pad.phys + "\n")
|
||||
File(input, "uniq").writeText(pad.uniq + "\n")
|
||||
File(input, "properties").writeText("0\n")
|
||||
File(input, "id").mkdirs()
|
||||
File(input, "id/bustype").writeText("%04x\n".format(pad.bus))
|
||||
File(input, "id/vendor").writeText("%04x\n".format(pad.vendor))
|
||||
File(input, "id/product").writeText("%04x\n".format(pad.product))
|
||||
File(input, "id/version").writeText("%04x\n".format(pad.version))
|
||||
File(input, "capabilities").mkdirs()
|
||||
for (name in CAPABILITIES) File(input, "capabilities/$name").writeText("0\n")
|
||||
link(File(input, "subsystem"), GUEST_CLASS)
|
||||
File(event, "uevent").writeText("MAJOR=$MAJOR\nMINOR=${MINOR_BASE + node}\nDEVNAME=input/event$node\n")
|
||||
File(event, "dev").writeText("$MAJOR:${MINOR_BASE + node}\n")
|
||||
link(File(event, "subsystem"), GUEST_CLASS)
|
||||
link(File(event, "device"), "$GUEST_DEVICES/input$node")
|
||||
val record = udevRecord(SystemClock.elapsedRealtimeNanos() / 1000)
|
||||
File(udevData, "+input:input$node").writeText(record)
|
||||
File(udevData, "c$MAJOR:${MINOR_BASE + node}").writeText(record)
|
||||
link(File(classDir, "input$node"), "$GUEST_DEVICES/input$node")
|
||||
link(File(classDir, "event$node"), "$GUEST_DEVICES/input$node/event$node")
|
||||
shown.add(node)
|
||||
Log.i(TAG, "pads: /dev/input/event$node listed for udev as \"${pad.name}\" (%04x:%04x)".format(pad.vendor, pad.product))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "pads: could not list /dev/input/event$node for udev: $e")
|
||||
hide(node)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hide(node: Int) {
|
||||
File(classDir, "event$node").delete()
|
||||
File(classDir, "input$node").delete()
|
||||
File(udevData, "c$MAJOR:${MINOR_BASE + node}").delete()
|
||||
File(udevData, "+input:input$node").delete()
|
||||
File(devices, "input$node").let { FileUtils.clear(it); it.delete() }
|
||||
if (shown.remove(node)) Log.i(TAG, "pads: /dev/input/event$node no longer listed for udev")
|
||||
}
|
||||
|
||||
private fun link(file: File, target: String) {
|
||||
file.delete()
|
||||
Os.symlink(target, file.path)
|
||||
}
|
||||
|
||||
data class Pad(
|
||||
val bus: Int,
|
||||
val vendor: Int,
|
||||
val product: Int,
|
||||
val version: Int,
|
||||
val name: String,
|
||||
val phys: String,
|
||||
val uniq: String,
|
||||
) {
|
||||
fun uevent(): String =
|
||||
"PRODUCT=%x/%x/%x/%x\nNAME=\"$name\"\nPHYS=\"$phys\"\nUNIQ=\"$uniq\"\nPROP=0\n".format(bus, vendor, product, version)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
const val GUEST_DEVICES = "/sys/devices/virtual/input"
|
||||
const val GUEST_CLASS = "/sys/class/input"
|
||||
private const val MAJOR = 13
|
||||
private const val MINOR_BASE = 64
|
||||
private const val FIRST_VIRTUAL = 16
|
||||
private const val NODE_LIMIT = 64
|
||||
private val NODE_FILE = Regex("(\\.uinput-)?event\\d+(\\.uevent)?")
|
||||
private val CAPABILITIES = listOf("ev", "key", "rel", "abs", "msc", "sw", "led", "snd", "ff")
|
||||
|
||||
@Volatile private var active: InputSysfsComponent? = null
|
||||
@Volatile private var padType = -1
|
||||
|
||||
fun padTypeChanged(type: Int) {
|
||||
padType = type
|
||||
active?.let { Thread(it::sync, "input-sysfs").start() }
|
||||
}
|
||||
|
||||
internal fun ringWithdrawn(deckPad: Boolean, type: Int) = deckPad && type != FakeInputWriter.PAD_TYPE_XBOX
|
||||
|
||||
internal fun isPadRecord(name: String) =
|
||||
Regex("c$MAJOR:(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it in MINOR_BASE until MINOR_BASE + NODE_LIMIT } == true ||
|
||||
Regex("\\+input:input(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it < NODE_LIMIT } == true
|
||||
|
||||
internal fun udevRecord(initializedUs: Long) =
|
||||
"I:$initializedUs\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\nE:ID_SERIAL=noserial\nG:seat\nG:uaccess\nQ:seat\nQ:uaccess\nV:1\n"
|
||||
|
||||
internal fun ringPad(node: Int, steamVirtual: Boolean) = if (steamVirtual) {
|
||||
Pad(3, 0x28de, 0x11ff, 0x110, "Microsoft X-Box 360 pad $node", "usb-fakeinput/input$node", "0000000000%02d".format(node))
|
||||
} else {
|
||||
Pad(3, 0x045e, 0x028e, 0x110, "Xbox 360 Controller ($node)", "usb-fakeinput/input$node", "0000000000%02d".format(node))
|
||||
}
|
||||
|
||||
internal fun virtualPad(uevent: String): Pad? {
|
||||
val values = uevent.lines().mapNotNull { line ->
|
||||
line.indexOf('=').takeIf { it > 0 }?.let { line.substring(0, it) to line.substring(it + 1).trim('"') }
|
||||
}.toMap()
|
||||
val id = values["PRODUCT"]?.split('/')?.mapNotNull { it.toIntOrNull(16) }?.takeIf { it.size == 4 } ?: return null
|
||||
return Pad(id[0], id[1], id[2], id[3], values["NAME"] ?: return null, values["PHYS"].orEmpty(), values["UNIQ"].orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
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
|
||||
@@ -18,35 +15,21 @@ 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
|
||||
|
||||
@@ -57,17 +40,7 @@ class PerfHud(context: Context) {
|
||||
handler.post { onPresentingWindowChanged?.invoke() }
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
override fun onGameFrame() { frames.incrementAndGet() }
|
||||
}
|
||||
|
||||
private val tick = object : Runnable {
|
||||
@@ -76,82 +49,28 @@ class PerfHud(context: Context) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val dt = (now - lastTick).coerceAtLeast(1L) / 1000f
|
||||
lastTick = now
|
||||
lastFps = frames.getAndSet(0) / dt
|
||||
text = readout(lastFps)
|
||||
if (showGraph) publishGraph() else clearGraph()
|
||||
handler.postDelayed(this, TICK_MS)
|
||||
text = line(frames.getAndSet(0) / dt)
|
||||
handler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
readOptions()
|
||||
if (!enabled || !anyPart) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (!running) start() else {
|
||||
if (!showGraph) clearGraph() else publishGraph()
|
||||
text = readout(lastFps)
|
||||
}
|
||||
if (enabled) start() else stop()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
readOptions()
|
||||
if (!enabled || !anyPart || running) return
|
||||
if (!enabled || running) return
|
||||
running = true
|
||||
lastTick = SystemClock.elapsedRealtime()
|
||||
lastFrameNs.set(0L)
|
||||
frames.set(0)
|
||||
graphWrite.set(0)
|
||||
WaylandCompositor.setGameListener(listener)
|
||||
handler.postDelayed(tick, TICK_MS)
|
||||
handler.post(tick)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -172,12 +91,4 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -118,9 +118,7 @@ object SessionArtifacts {
|
||||
|
||||
private fun scrubFolder(dir: File, record: Record) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
|
||||
if (LogRedactor.isBinary(f)) return@forEach
|
||||
// Too large to rewrite in memory: the folder must not be recorded as scrubbed.
|
||||
if (!LogRedactor.isText(f)) { record.failed = true; return@forEach }
|
||||
if (!LogRedactor.isText(f)) return@forEach
|
||||
val done = scrubFile(dir, f)
|
||||
if (done == null) record.failed = true else record.files[f] = done
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LegacyNames
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import java.io.File
|
||||
|
||||
object SessionFiles {
|
||||
@@ -82,7 +83,6 @@ 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-bwrap" to "usr/local/bin/droiddeck-bwrap",
|
||||
@@ -116,31 +116,14 @@ object SessionFiles {
|
||||
val msitools = listOf("msiinfo", "cabextract", "7z", "7z.so", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
|
||||
.map { "usr/local/lib/droiddeck-msitools/$it" } +
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
val handModels = listOf("left", "right").flatMap { side ->
|
||||
listOf("droiddeck_hand_$side.json", "hand.mtl", "hand.png", "hand_open.obj", "hand_fist.obj", "thumb_index_open.obj",
|
||||
"thumb_index_pinch.obj")
|
||||
.map { "droiddeck/resources/rendermodels/droiddeck_hand_$side/$it" }
|
||||
}
|
||||
val vr = (listOf(
|
||||
"droiddeck/bin/linuxarm64/driver_droiddeck.so",
|
||||
"droiddeck/bin/linuxarm64/xr-display-mode",
|
||||
"droiddeck/driver.vrdrivermanifest",
|
||||
"droiddeck/resources/settings/default.vrsettings",
|
||||
"layer/libVkLayer_droiddeck_vr.so",
|
||||
"layer/libdroiddeck_vr_scheduling.so",
|
||||
"layer/VK_LAYER_DROIDDECK_vr.json",
|
||||
) + handModels).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } +
|
||||
listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to
|
||||
"usr/share/vulkan/implicit_layer.d/droiddeck_vr.json")
|
||||
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
|
||||
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
|
||||
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, _) ->
|
||||
) + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
@@ -164,6 +147,7 @@ object SessionFiles {
|
||||
}
|
||||
if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged")
|
||||
}
|
||||
VrPack.stageGuest(context, root)
|
||||
File(root, "usr/local/bin/bannerlator-steam-compat").delete()
|
||||
LegacyNames.migrateRootfs(root)
|
||||
ComponentsManager.migrateLaunchDir(context)
|
||||
|
||||
@@ -66,12 +66,15 @@ object SessionLogShare {
|
||||
return zip
|
||||
}
|
||||
|
||||
/** Every entry is scrubbed line by line, whatever its size: a raw copy can carry tokens out of the device. */
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubForShare(f, w)
|
||||
w.flush()
|
||||
if (LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubForShare(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
}
|
||||
z.closeEntry()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ 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"
|
||||
@@ -157,43 +156,6 @@ 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) =
|
||||
@@ -991,12 +953,6 @@ 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) ||
|
||||
|
||||
@@ -22,8 +22,6 @@ import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.SessionActivity
|
||||
@@ -59,7 +57,6 @@ class SessionService : Service() {
|
||||
|
||||
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
|
||||
private var deckBinds: List<String> = emptyList()
|
||||
private var inputSysfs: InputSysfsComponent? = null
|
||||
private val stopLock = Any()
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
@@ -71,7 +68,6 @@ 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
|
||||
@@ -80,7 +76,6 @@ class SessionService : Service() {
|
||||
private var signInHold = false
|
||||
private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible || signInHold, screenOn, xrActivityVisible)
|
||||
private var screenOn = true
|
||||
private var sessionDisplayId = Display.DEFAULT_DISPLAY
|
||||
private var manualPauseRequested = false
|
||||
private var steamSleepToken: String? = null
|
||||
private var nativeSleepToken: String? = null
|
||||
@@ -100,8 +95,8 @@ class SessionService : Service() {
|
||||
private val screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> screenOn = readScreenOn(false)
|
||||
Intent.ACTION_SCREEN_ON -> screenOn = readScreenOn(true)
|
||||
Intent.ACTION_SCREEN_OFF -> screenOn = false
|
||||
Intent.ACTION_SCREEN_ON -> screenOn = true
|
||||
else -> return
|
||||
}
|
||||
if (screenOn) resumeSteamSleepOnReturn()
|
||||
@@ -109,49 +104,17 @@ class SessionService : Service() {
|
||||
updateSuspendPolicy()
|
||||
}
|
||||
}
|
||||
private var displayListenerRegistered = false
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) {}
|
||||
override fun onDisplayRemoved(displayId: Int) {
|
||||
if (displayId != sessionDisplayId) return
|
||||
sessionDisplayId = Display.DEFAULT_DISPLAY
|
||||
refreshScreenOn()
|
||||
}
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
if (displayId == sessionDisplayId && displayId != Display.DEFAULT_DISPLAY) refreshScreenOn()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readScreenOn(interactive: Boolean? = null): Boolean {
|
||||
val id = sessionDisplayId
|
||||
val state = if (id == Display.DEFAULT_DISPLAY) null
|
||||
else (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.getDisplay(id)?.state
|
||||
return sessionScreenOn(id == Display.DEFAULT_DISPLAY,
|
||||
interactive ?: (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true, state)
|
||||
}
|
||||
|
||||
private fun refreshScreenOn() {
|
||||
val on = readScreenOn()
|
||||
if (on == screenOn) return
|
||||
screenOn = on
|
||||
if (on) resumeSteamSleepOnReturn()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
}
|
||||
@Volatile private var sessionGen = 0
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
screenOn = readScreenOn()
|
||||
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
screenOn = power?.isInteractive ?: true
|
||||
registerReceiver(screenReceiver, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
})
|
||||
screenReceiverRegistered = true
|
||||
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.let {
|
||||
it.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
||||
displayListenerRegistered = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -200,19 +163,12 @@ class SessionService : Service() {
|
||||
ACTION_ACTIVITY_VISIBLE -> {
|
||||
if (!SessionState.pipActive) pipTask = false
|
||||
activityVisible = true
|
||||
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
|
||||
screenOn = readScreenOn()
|
||||
suspendDownloadNow = false
|
||||
resumeSteamSleepOnReturn()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_ACTIVITY_DISPLAY -> {
|
||||
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
|
||||
refreshScreenOn()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_ACTIVITY_HIDDEN -> {
|
||||
activityVisible = false
|
||||
suspendAttemptFailed = false
|
||||
@@ -276,8 +232,7 @@ class SessionService : Service() {
|
||||
activityVisible = true
|
||||
xrActivityVisible = false
|
||||
signInHold = false
|
||||
sessionDisplayId = intent?.getIntExtra(EXTRA_DISPLAY_ID, Display.DEFAULT_DISPLAY) ?: Display.DEFAULT_DISPLAY
|
||||
screenOn = readScreenOn()
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
steamDownloadMonitor.reset()
|
||||
@@ -395,12 +350,6 @@ 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)
|
||||
@@ -419,7 +368,6 @@ class SessionService : Service() {
|
||||
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
|
||||
SessionState.deckPad = false
|
||||
deckBinds = emptyList()
|
||||
inputSysfs = null
|
||||
if (controllersOn) {
|
||||
addControllerEnvironment(guest, fakeInputDir, sessionDir)
|
||||
SessionState.padBridge?.syncConnectedControllers()
|
||||
@@ -738,7 +686,6 @@ class SessionService : Service() {
|
||||
val padType = SessionPrefs.steamController(this)
|
||||
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
|
||||
InputSysfsComponent.padTypeChanged(SessionPrefs.padType(padType))
|
||||
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
|
||||
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
|
||||
}
|
||||
@@ -752,9 +699,6 @@ class SessionService : Service() {
|
||||
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
|
||||
guest.add("FAKE_EVDEV_LOG=1")
|
||||
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
|
||||
inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"),
|
||||
File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM)
|
||||
.takeIf { it.prepare() }
|
||||
SessionState.fakeInputDir = fakeInputDir
|
||||
}
|
||||
|
||||
@@ -769,11 +713,6 @@ class SessionService : Service() {
|
||||
val binds = ArrayList<String>()
|
||||
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
|
||||
if (controllersOn) binds.addAll(deckBinds)
|
||||
if (controllersOn) inputSysfs?.let {
|
||||
it.attach(this)
|
||||
components.add(it)
|
||||
binds.addAll(it.binds())
|
||||
}
|
||||
val battery = BatteryComponent(
|
||||
File(filesDir, "session/sys/power_supply"),
|
||||
File(LinuxRuntime.rootDir(this), "run/vpower"),
|
||||
@@ -809,7 +748,6 @@ 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)
|
||||
@@ -821,7 +759,6 @@ 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) {
|
||||
@@ -1085,7 +1022,7 @@ class SessionService : Service() {
|
||||
|
||||
private fun resumeSession(panelVisible: Boolean = true) {
|
||||
if (panelVisible) activityVisible = true
|
||||
screenOn = readScreenOn()
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
|
||||
@@ -1280,13 +1217,13 @@ class SessionService : Service() {
|
||||
|
||||
private fun updateSuspendPolicy() {
|
||||
if (!SessionState.running) 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) {
|
||||
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 (downloadsBlockingSuspend) acquireLocks()
|
||||
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
|
||||
@@ -1311,7 +1248,7 @@ class SessionService : Service() {
|
||||
suspendOperationPending = true
|
||||
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
|
||||
"policy" to suspendPolicy, "activityVisible" to activityVisible,
|
||||
"screenOn" to screenOn, "display" to sessionDisplayId, "steamSleep" to (steamSleepToken != null),
|
||||
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
|
||||
"xrActivityVisible" to xrActivityVisible,
|
||||
"nativeSleep" to (nativeSleepToken != null),
|
||||
))
|
||||
@@ -1575,10 +1512,6 @@ class SessionService : Service() {
|
||||
unregisterReceiver(screenReceiver)
|
||||
screenReceiverRegistered = false
|
||||
}
|
||||
if (displayListenerRegistered) {
|
||||
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.unregisterDisplayListener(displayListener)
|
||||
displayListenerRegistered = false
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -1721,7 +1654,6 @@ class SessionService : Service() {
|
||||
LinuxRuntime.rootDir(this@SessionService),
|
||||
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
|
||||
client = SessionState.steamClientArch,
|
||||
mounts = steamLibraryMounts,
|
||||
)
|
||||
if (wasActive != downloadActive) refreshNotification()
|
||||
updateSuspendPolicy()
|
||||
@@ -1767,8 +1699,6 @@ class SessionService : Service() {
|
||||
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
|
||||
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
|
||||
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
|
||||
private const val ACTION_ACTIVITY_DISPLAY = "com.droiddeck.launcher.ACTIVITY_DISPLAY"
|
||||
private const val EXTRA_DISPLAY_ID = "displayId"
|
||||
private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
|
||||
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
|
||||
private const val ACTION_SIGN_IN_HOLD = "com.droiddeck.launcher.SIGN_IN_HOLD"
|
||||
@@ -1805,10 +1735,8 @@ class SessionService : Service() {
|
||||
fun start(
|
||||
context: Context, mode: String = MODE_STEAM, program: String? = null,
|
||||
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
|
||||
displayId: Int = Display.DEFAULT_DISPLAY,
|
||||
) {
|
||||
val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode)
|
||||
.putExtra(EXTRA_DISPLAY_ID, displayId)
|
||||
if (program != null) intent.putExtra(EXTRA_PROGRAM, program)
|
||||
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
|
||||
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
|
||||
@@ -1829,15 +1757,10 @@ class SessionService : Service() {
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PIP_BEGIN))
|
||||
}
|
||||
|
||||
fun setActivityVisible(context: Context, visible: Boolean, displayId: Int = Display.DEFAULT_DISPLAY) {
|
||||
fun setActivityVisible(context: Context, visible: Boolean) {
|
||||
if (!SessionState.running) return
|
||||
val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(action).putExtra(EXTRA_DISPLAY_ID, displayId))
|
||||
}
|
||||
|
||||
fun setActivityDisplay(context: Context, displayId: Int) {
|
||||
if (!SessionState.running) return
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_ACTIVITY_DISPLAY).putExtra(EXTRA_DISPLAY_ID, displayId))
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(action))
|
||||
}
|
||||
|
||||
fun setXrActivityVisible(context: Context, visible: Boolean) {
|
||||
|
||||
@@ -2,22 +2,14 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import android.os.Process
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
internal class SessionSuspendController(
|
||||
private val sessionRoot: () -> Int,
|
||||
private val helperRoots: () -> List<Int>,
|
||||
private val suspendAudio: () -> Unit,
|
||||
private val resumeAudio: () -> Unit,
|
||||
private val procRoot: File = File("/proc"),
|
||||
private val signal: (Int, Int) -> Unit = Process::sendSignal,
|
||||
private val threadSignal: (Int, Int, Int) -> Unit = { pid, tid, signal -> ThreadSignals.tgkill(pid, tid, signal) },
|
||||
private val sleep: (Long) -> Unit = Thread::sleep,
|
||||
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
|
||||
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
|
||||
) {
|
||||
private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD }
|
||||
|
||||
@@ -32,95 +24,96 @@ internal class SessionSuspendController(
|
||||
val parent: Int,
|
||||
val started: Long,
|
||||
val state: Char,
|
||||
val tracer: Int,
|
||||
)
|
||||
|
||||
private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long)
|
||||
|
||||
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
@Volatile private var closing = false
|
||||
private var frozen = emptyList<ProcessRecord>()
|
||||
|
||||
fun freeze(completion: (Boolean) -> Unit) {
|
||||
submit({
|
||||
val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started }
|
||||
var complete = false
|
||||
var interrupted = false
|
||||
try {
|
||||
val root = records.values.firstOrNull { it.role == Role.SESSION_ROOT }
|
||||
?: readProcess(sessionRoot())?.takeIf { it.pid > 1 }?.let {
|
||||
ProcessRecord(it.pid, it.started, Role.SESSION_ROOT)
|
||||
} ?: throw IOException("Session root is unavailable")
|
||||
records[root.pid to root.started] = root
|
||||
runAudio(suspendAudio)
|
||||
val helpers = helperRoots().filter { it > 1 && it != root.pid }.distinct()
|
||||
for (helper in helpers) {
|
||||
if (records.values.any { it.pid == helper && it.role == Role.HELPER_ROOT }) continue
|
||||
readProcess(helper)?.let {
|
||||
records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT)
|
||||
}
|
||||
}
|
||||
val deadline = nowMillis() + FREEZE_TIMEOUT_MS
|
||||
var previous: Map<TaskIdentity, Long>? = null
|
||||
for (pass in 0 until FREEZE_MAX_PASSES) {
|
||||
if (closing || nowMillis() >= deadline) break
|
||||
try {
|
||||
if (readProcess(root.pid)?.started != root.started) break
|
||||
addSnapshot(records, root.pid, helpers)
|
||||
if (readProcess(root.pid)?.started != root.started) break
|
||||
stop(records.values.toList())
|
||||
sleep(if (pass == 0) 70L else 35L)
|
||||
if (closing || nowMillis() >= deadline) break
|
||||
val current = stoppedSnapshot(records.values.toList(), root)
|
||||
if (current != null && current == previous) {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
previous = current
|
||||
} catch (_: IOException) {
|
||||
previous = null
|
||||
sleep(35L)
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
interrupted = true
|
||||
Thread.interrupted()
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
frozen = records.values.toList()
|
||||
if (!complete) {
|
||||
if (restoreSafely(frozen)) frozen = emptyList()
|
||||
runAudio(resumeAudio)
|
||||
}
|
||||
if (interrupted) Thread.currentThread().interrupt()
|
||||
submit {
|
||||
if (closing) {
|
||||
completion(false)
|
||||
return@submit
|
||||
}
|
||||
completion(complete)
|
||||
}, { completion(false) })
|
||||
if (frozen.isNotEmpty()) {
|
||||
val fullyFrozen = frozen.any { it.role == Role.SESSION_ROOT } && frozen.all(::isStoppedOrGone)
|
||||
if (!fullyFrozen && restore(frozen)) frozen = emptyList()
|
||||
completion(fullyFrozen)
|
||||
return@submit
|
||||
}
|
||||
val records = LinkedHashMap<Pair<Int, Long>, ProcessRecord>()
|
||||
val root = sessionRoot()
|
||||
if (root <= 1) {
|
||||
completion(false)
|
||||
return@submit
|
||||
}
|
||||
runAudio(suspendAudio)
|
||||
val helpers = helperRoots().filter { it > 1 && it != root }.distinct()
|
||||
var stablePasses = 0
|
||||
var complete = false
|
||||
repeat(10) { pass ->
|
||||
if (closing) {
|
||||
restore(records.values.toList())
|
||||
runAudio(resumeAudio)
|
||||
completion(false)
|
||||
return@submit
|
||||
}
|
||||
val includeRoots = pass >= 2
|
||||
addSnapshot(records, root, helpers, includeRoots)
|
||||
stop(records.values.toList(), includeRoots)
|
||||
Thread.sleep(if (pass == 0) 70L else 35L)
|
||||
val rootCaptured = records.values.any { it.role == Role.SESSION_ROOT }
|
||||
if (rootCaptured && records.isNotEmpty() && records.values.all(::isStoppedOrGone)) {
|
||||
stablePasses++
|
||||
if (stablePasses >= 2) complete = true
|
||||
} else {
|
||||
stablePasses = 0
|
||||
}
|
||||
}
|
||||
if (complete) {
|
||||
frozen = records.values.toList()
|
||||
completion(true)
|
||||
} else {
|
||||
val restored = restore(records.values.toList())
|
||||
runAudio(resumeAudio)
|
||||
if (!restored) frozen = records.values.toList()
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resume(completion: (Boolean) -> Unit) {
|
||||
submit({
|
||||
submit {
|
||||
val records = frozen
|
||||
runAudio(resumeAudio)
|
||||
val resumed = restoreSafely(records)
|
||||
val resumed = restore(records)
|
||||
if (resumed) frozen = emptyList()
|
||||
completion(resumed)
|
||||
}, { completion(false) })
|
||||
}
|
||||
}
|
||||
|
||||
fun closeAndResume(completion: () -> Unit) {
|
||||
closing = true
|
||||
submit({
|
||||
if (restoreSafely(frozen)) frozen = emptyList()
|
||||
submit {
|
||||
restore(frozen)
|
||||
frozen = emptyList()
|
||||
completion()
|
||||
executor.shutdown()
|
||||
}, completion)
|
||||
}
|
||||
}
|
||||
|
||||
private fun submit(task: () -> Unit, rejected: () -> Unit) {
|
||||
private fun submit(task: () -> Unit) {
|
||||
try {
|
||||
executor.execute(task)
|
||||
} catch (_: RejectedExecutionException) {
|
||||
rejected()
|
||||
executor.execute {
|
||||
try {
|
||||
task()
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
} catch (_: RuntimeException) {
|
||||
task()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,25 +128,23 @@ internal class SessionSuspendController(
|
||||
records: MutableMap<Pair<Int, Long>, ProcessRecord>,
|
||||
root: Int,
|
||||
helpers: List<Int>,
|
||||
includeRoots: Boolean,
|
||||
) {
|
||||
val processes = readProcesses()
|
||||
val byPid = processes.associateBy { it.pid }
|
||||
val rootRecord = records.values.first { it.role == Role.SESSION_ROOT }
|
||||
if (byPid[root]?.started != rootRecord.started) throw IOException("Session root exited or changed")
|
||||
val liveHelpers = helpers.filter { pid ->
|
||||
records.values.any { it.pid == pid && it.role == Role.HELPER_ROOT && it.started == byPid[pid]?.started }
|
||||
for (process in descendants(root, processes, byPid)) {
|
||||
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.SESSION_CHILD)
|
||||
}
|
||||
for (process in descendants(root, processes, byPid) + processes.filter { it.tracer == root }) {
|
||||
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.SESSION_CHILD))
|
||||
}
|
||||
for (helper in liveHelpers) {
|
||||
for (helper in helpers) {
|
||||
for (process in descendants(helper, processes, byPid)) {
|
||||
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.HELPER_CHILD))
|
||||
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.HELPER_CHILD)
|
||||
}
|
||||
}
|
||||
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
|
||||
for (helper in liveHelpers) {
|
||||
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
|
||||
if (includeRoots) {
|
||||
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
|
||||
for (helper in helpers) {
|
||||
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,84 +168,36 @@ internal class SessionSuspendController(
|
||||
}
|
||||
|
||||
private fun readProcesses(): List<ProcessInfo> =
|
||||
(procRoot.listFiles() ?: throw IOException("Cannot enumerate processes")).mapNotNull { entry ->
|
||||
File("/proc").listFiles().orEmpty().mapNotNull { entry ->
|
||||
val pid = entry.name.toIntOrNull() ?: return@mapNotNull null
|
||||
try { readProcess(pid) } catch (_: IOException) { null }
|
||||
readProcess(pid)
|
||||
}
|
||||
|
||||
private fun readProcess(pid: Int): ProcessInfo? {
|
||||
val base = File(procRoot, pid.toString())
|
||||
try {
|
||||
val fields = readStat(File(base, "stat"))
|
||||
val tracer = File(base, "status").useLines { lines ->
|
||||
lines.firstOrNull { it.startsWith("TracerPid:") }?.substringAfter(':')?.trim()?.toIntOrNull() ?: 0
|
||||
}
|
||||
return ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first(), tracer)
|
||||
} catch (error: IOException) {
|
||||
if (procRoot.list()?.contains(pid.toString()) == false) return null
|
||||
throw error
|
||||
val stat = try {
|
||||
File("/proc/$pid/stat").readText()
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStat(file: File): List<String> {
|
||||
val stat = file.readText()
|
||||
val close = stat.lastIndexOf(')')
|
||||
if (close < 0 || close + 2 >= stat.length) throw IOException("Invalid process stat: $file")
|
||||
if (close < 0 || close + 2 >= stat.length) return null
|
||||
val fields = stat.substring(close + 2).trim().split(Regex("\\s+"))
|
||||
if (fields.size < 20) throw IOException("Incomplete process stat: $file")
|
||||
return fields
|
||||
}
|
||||
|
||||
private fun stoppedSnapshot(records: List<ProcessRecord>, root: ProcessRecord): Map<TaskIdentity, Long>? {
|
||||
// PRoot can leave the leader stopped while native worker threads keep running.
|
||||
val tasks = LinkedHashMap<TaskIdentity, Long>()
|
||||
var stopped = true
|
||||
for (record in records) {
|
||||
val current = readProcess(record.pid)
|
||||
if (current == null || current.started != record.started) {
|
||||
if (record == root) throw IOException("Session root exited or changed")
|
||||
continue
|
||||
}
|
||||
val entries = File(procRoot, "${record.pid}/task").listFiles()
|
||||
?: throw IOException("Cannot enumerate threads: ${record.pid}")
|
||||
if (entries.none { it.name.toIntOrNull() != null }) throw IOException("Empty thread inventory: ${record.pid}")
|
||||
var liveRoot = false
|
||||
for (entry in entries) {
|
||||
val tid = entry.name.toIntOrNull() ?: continue
|
||||
val fields = readStat(File(entry, "stat"))
|
||||
val state = fields[0].first()
|
||||
if (state != 'Z' && state != 'X') liveRoot = true
|
||||
if (state != 'T' && state != 't' && state != 'Z' && state != 'X') stopped = false
|
||||
tasks[TaskIdentity(record, tid, fields[19].toLong())] = fields[11].toLong() + fields[12].toLong()
|
||||
}
|
||||
if (record == root && !liveRoot) throw IOException("Session root has no live threads")
|
||||
if (readProcess(record.pid)?.started != record.started) throw IOException("Process changed during inspection")
|
||||
}
|
||||
return tasks.takeIf { stopped && it.isNotEmpty() }
|
||||
}
|
||||
|
||||
// PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts
|
||||
// thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its
|
||||
// signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP.
|
||||
private fun stop(records: List<ProcessRecord>) {
|
||||
for (record in records.filter { it.role == Role.SESSION_ROOT }) send(record, SIGSTOP)
|
||||
for (record in records.filter { it.role == Role.HELPER_ROOT }) send(record, SIGSTOP)
|
||||
for (record in records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }) {
|
||||
stopThreads(record)
|
||||
if (fields.size < 20) return null
|
||||
return try {
|
||||
ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first())
|
||||
} catch (_: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopThreads(record: ProcessRecord) {
|
||||
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
|
||||
if (current == null || current.started != record.started) return
|
||||
for (entry in File(procRoot, "${record.pid}/task").listFiles().orEmpty()) {
|
||||
val tid = entry.name.toIntOrNull() ?: continue
|
||||
val state = try { readStat(File(entry, "stat"))[0].first() } catch (_: IOException) { continue }
|
||||
if (state == 'T' || state == 't' || state == 'Z' || state == 'X') continue
|
||||
try {
|
||||
threadSignal(record.pid, tid, SIGSTOP)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
private fun stop(records: List<ProcessRecord>, includeRoots: Boolean) {
|
||||
val descendants = records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }
|
||||
val session = records.filter { it.role == Role.SESSION_ROOT }
|
||||
val helpers = records.filter { it.role == Role.HELPER_ROOT }
|
||||
for (record in descendants) send(record, SIGSTOP)
|
||||
if (includeRoots) {
|
||||
for (record in session) send(record, SIGSTOP)
|
||||
for (record in helpers) send(record, SIGSTOP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +213,7 @@ internal class SessionSuspendController(
|
||||
for (record in ordered) send(record, SIGCONT)
|
||||
val waiting = ordered.toMutableList()
|
||||
repeat(RESTORE_CHECKS) { check ->
|
||||
sleep(25L)
|
||||
Thread.sleep(25L)
|
||||
waiting.removeAll { isRunningOrGone(it) }
|
||||
if (waiting.isEmpty()) return true
|
||||
if (check % 8 == 7) for (record in waiting) send(record, SIGCONT)
|
||||
@@ -278,40 +221,30 @@ internal class SessionSuspendController(
|
||||
return false
|
||||
}
|
||||
|
||||
private fun restoreSafely(records: List<ProcessRecord>): Boolean = try {
|
||||
restore(records)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun send(record: ProcessRecord, signal: Int) {
|
||||
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
|
||||
if (current == null || current.started != record.started) return
|
||||
val current = readProcess(record.pid) ?: return
|
||||
if (current.started != record.started) return
|
||||
try {
|
||||
this.signal(record.pid, signal)
|
||||
Process.sendSignal(record.pid, signal)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isStoppedOrGone(record: ProcessRecord): Boolean {
|
||||
val current = readProcess(record.pid) ?: return true
|
||||
if (current.started != record.started) return true
|
||||
return current.state == 'T' || current.state == 't' || current.state == 'Z' || current.state == 'X'
|
||||
}
|
||||
|
||||
private fun isRunningOrGone(record: ProcessRecord): Boolean {
|
||||
val current = try { readProcess(record.pid) } catch (_: Exception) { return false }
|
||||
if (current == null || current.started != record.started) return true
|
||||
if (current.state == 'T' || current.state == 't') return false
|
||||
// A traced worker in 't' is usually PRoot handling a system call; 'T' is a job-control stop.
|
||||
return File(procRoot, "${record.pid}/task").listFiles().orEmpty().none { entry ->
|
||||
entry.name.toIntOrNull() != null &&
|
||||
try { readStat(File(entry, "stat"))[0].first() == 'T' } catch (_: IOException) { false }
|
||||
}
|
||||
val current = readProcess(record.pid) ?: return true
|
||||
if (current.started != record.started) return true
|
||||
return current.state != 'T' && current.state != 't'
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SIGSTOP = 19
|
||||
const val SIGCONT = 18
|
||||
const val RESTORE_CHECKS = 80
|
||||
const val FREEZE_TIMEOUT_MS = 8_000L
|
||||
const val FREEZE_MAX_PASSES = 256
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.view.Display
|
||||
|
||||
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
|
||||
xrVisible || (panelVisible && screenOn)
|
||||
|
||||
internal fun sessionScreenOn(onDefaultDisplay: Boolean, interactive: Boolean, displayState: Int?): Boolean =
|
||||
if (onDefaultDisplay || displayState == null || displayState == Display.STATE_UNKNOWN) interactive
|
||||
else displayState == Display.STATE_ON || displayState == Display.STATE_VR || displayState == Display.STATE_ON_SUSPEND
|
||||
@@ -13,7 +13,7 @@ object SettingsReset {
|
||||
internal val SESSION_KEYS = setOf(
|
||||
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
|
||||
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
|
||||
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "touch", "osc", "shape", "steamController",
|
||||
"hud", "hudDeck", "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",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
class SteamClientSetupFailure(val curlError: String?) {
|
||||
val download get() = curlError != null
|
||||
|
||||
companion object {
|
||||
const val MARKER = "== steam: the Steam client could not be set up"
|
||||
private const val STEP = "checking the Steam client"
|
||||
private const val X64_STEP = "checking the x86-64 Steam client"
|
||||
private val CURL_ERROR = Regex("""curl: \(\d+\).*""")
|
||||
|
||||
fun find(lines: Sequence<String>): SteamClientSetupFailure? {
|
||||
var curlError: String? = null
|
||||
var found: SteamClientSetupFailure? = null
|
||||
lines.forEach { line ->
|
||||
when {
|
||||
line.contains(STEP) || line.contains(X64_STEP) -> {
|
||||
curlError = null
|
||||
found = null
|
||||
}
|
||||
line.contains(MARKER) -> found = SteamClientSetupFailure(curlError)
|
||||
else -> CURL_ERROR.find(line)?.let { curlError = it.value.trim() }
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,8 @@ 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,
|
||||
mounts: Map<String, File> = emptyMap()): Boolean {
|
||||
val current = manifests(runtimeRoot, mounts).associate { it.path to read(it) }
|
||||
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) }
|
||||
val progressed = current.any { (path, state) ->
|
||||
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
|
||||
}
|
||||
@@ -99,7 +98,7 @@ internal class SteamDownloadMonitor {
|
||||
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
|
||||
}
|
||||
|
||||
private fun manifests(runtimeRoot: File, mounts: Map<String, File>): List<File> {
|
||||
private fun manifests(runtimeRoot: File): List<File> {
|
||||
val steamRoots = linkedSetOf<File>()
|
||||
for (home in STEAM_HOMES) {
|
||||
val primary = File(runtimeRoot, home)
|
||||
@@ -107,7 +106,7 @@ internal class SteamDownloadMonitor {
|
||||
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
|
||||
runCatching {
|
||||
PATH.findAll(libraryFolders.readText()).forEach { match ->
|
||||
steamRoots += hostPath(runtimeRoot, match.groupValues[1], mounts)
|
||||
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,15 +117,6 @@ 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
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal object ThreadSignals {
|
||||
init {
|
||||
System.loadLibrary("sdnative")
|
||||
}
|
||||
|
||||
@JvmStatic external fun tgkill(tgid: Int, tid: Int, signal: Int): Int
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,6 @@ internal object LoginFlows {
|
||||
override val userAgent: String get() = GogAuth.GALAXY_UA
|
||||
override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX)
|
||||
|
||||
override fun forgetSignIn(done: () -> Unit) {
|
||||
runCatching { WebCookies.expire(GogAuth.SIGN_IN_URLS, GogAuth.COOKIE_DOMAIN, listOf("/")) { done() } }
|
||||
.onFailure {
|
||||
Log.w(TAG, "gog: could not clear the previous web sign-in: ${it.javaClass.simpleName}")
|
||||
done()
|
||||
}
|
||||
}
|
||||
|
||||
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
|
||||
val fragment = Uri.parse(url).fragment ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
|
||||
val frag = Uri.parse("x://x?$fragment")
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Message
|
||||
import android.util.Log
|
||||
@@ -19,7 +18,6 @@ import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
@@ -40,12 +38,8 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
const val EXTRA_STORE = "store"
|
||||
const val EXTRA_CLIENT_URL = "client_url"
|
||||
private const val KEY_STATE = "oauth_state"
|
||||
private const val KEY_SIGN_IN_CLEARED = "sign_in_cleared"
|
||||
@Volatile internal var floodColor: Int? = null
|
||||
internal fun chromeUserAgent(webView: String?): String =
|
||||
webView?.replace(Regex(" Build/[^;)]*"), "")?.replace("; wv)", ")")?.replace(" Version/4.0", "")
|
||||
?.takeIf { "Chrome/" in it }
|
||||
?: "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
|
||||
internal const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
|
||||
|
||||
fun generateState(): String {
|
||||
val bytes = ByteArray(24)
|
||||
@@ -60,7 +54,6 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
fun isRedirect(url: String): Boolean
|
||||
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
|
||||
val readsPage: Boolean get() = false
|
||||
fun forgetSignIn(done: () -> Unit) = done()
|
||||
}
|
||||
|
||||
private var store: Store? = null
|
||||
@@ -81,8 +74,6 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
private var oauthState: String? = null
|
||||
private val captured = AtomicBoolean(false)
|
||||
private var flooded = false
|
||||
private val chromeUa by lazy { chromeUserAgent(runCatching { WebSettings.getDefaultUserAgent(this) }.getOrNull()) }
|
||||
private var signInCleared = false
|
||||
|
||||
private fun dp(value: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
|
||||
|
||||
@@ -118,18 +109,12 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
})
|
||||
val start = flow!!.startUrl
|
||||
signInCleared = savedInstanceState?.getBoolean(KEY_SIGN_IN_CLEARED) == true
|
||||
if (signInCleared) webView.loadUrl(start)
|
||||
else flow!!.forgetSignIn {
|
||||
signInCleared = true
|
||||
if (!isDestroyed) webView.loadUrl(start)
|
||||
}
|
||||
webView.loadUrl(flow!!.startUrl)
|
||||
}
|
||||
|
||||
private fun newFlow(state: String): Flow? {
|
||||
val url = clientUrl
|
||||
if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url, chromeUa)
|
||||
if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url)
|
||||
return store?.let { LoginFlows.forStore(it, state) }
|
||||
}
|
||||
|
||||
@@ -240,7 +225,7 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean {
|
||||
if (resultMsg == null) return false
|
||||
dismissPopup()
|
||||
val child = newWebView(chromeUa, isPopup = true)
|
||||
val child = newWebView(CHROME_UA, isPopup = true)
|
||||
popupWebView = child
|
||||
popupHost.addView(child, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
popupHost.visibility = View.VISIBLE
|
||||
@@ -270,8 +255,6 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private inner class Client(private val isPopup: Boolean) : WebViewClient() {
|
||||
private var mainFrameUrl: String? = null
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
Log.d(TAG, "page[popup=$isPopup]: ${StoreLog.redactUrl(url)}")
|
||||
if (url != null && view != null && flow?.isRedirect(url) == true && flow?.readsPage != true) complete(view, url)
|
||||
@@ -294,14 +277,10 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
|
||||
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
|
||||
handler?.cancel()
|
||||
val url = error?.url
|
||||
if (url == null || url == view?.url || url == mainFrameUrl) showError(getString(R.string.stores_login_error_ssl))
|
||||
showError(getString(R.string.stores_login_error_ssl))
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
if (request.isForMainFrame) mainFrameUrl = request.url.toString()
|
||||
return route(view, request.url)
|
||||
}
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = route(view, request.url)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = route(view, Uri.parse(url))
|
||||
@@ -312,8 +291,8 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
if (f.isRedirect(url) && !f.readsPage) { complete(view, url); return true }
|
||||
if (!isPopup && store == Store.GOG) {
|
||||
if (GogAuth.isGogHost(uri.host) && view.settings.userAgentString != f.userAgent) { view.settings.userAgentString = f.userAgent; return false }
|
||||
if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != chromeUa) {
|
||||
view.settings.userAgentString = chromeUa
|
||||
if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != CHROME_UA) {
|
||||
view.settings.userAgentString = CHROME_UA
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(view, true)
|
||||
view.loadUrl(url)
|
||||
return true
|
||||
@@ -351,7 +330,6 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
oauthState?.let { outState.putString(KEY_STATE, it) }
|
||||
outState.putBoolean(KEY_SIGN_IN_CLEARED, signInCleared)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.CookieManager
|
||||
|
||||
internal object WebCookies {
|
||||
fun expired(header: String?, domain: String, paths: List<String>, host: String? = null): List<String> {
|
||||
val domains = listOfNotNull(domain, host?.takeIf { it.endsWith(domain) })
|
||||
return header.orEmpty().split(';').map { it.substringBefore('=').trim() }.filter { it.isNotEmpty() }.distinct()
|
||||
.flatMap { name ->
|
||||
paths.flatMap { path ->
|
||||
listOf("$name=; Max-Age=0; Path=$path; Secure") + domains.map { "$name=; Max-Age=0; Path=$path; Secure; Domain=$it" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun expire(urls: List<String>, domain: String, paths: List<String>, done: (Int) -> Unit) {
|
||||
val cookies = CookieManager.getInstance()
|
||||
val lines = urls.flatMap { url -> expired(cookies.getCookie(url), domain, paths, Uri.parse(url).host).map { url to it } }
|
||||
if (lines.isEmpty()) {
|
||||
done(0)
|
||||
return
|
||||
}
|
||||
var left = lines.size
|
||||
for ((url, line) in lines) {
|
||||
cookies.setCookie(url, line) {
|
||||
if (--left == 0) {
|
||||
cookies.flush()
|
||||
done(lines.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreLog
|
||||
import com.droiddeck.launcher.stores.StoreLoginActivity
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.WebCookies
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.SteamLiveShortcuts
|
||||
@@ -109,15 +108,28 @@ object EpicClient {
|
||||
fun forgetWebSignIn() {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
runCatching {
|
||||
WebCookies.expire(COOKIE_URLS, COOKIE_DOMAIN, COOKIE_PATHS) { cleared ->
|
||||
Log.i(TAG, "cleared the Epic web sign-in ($cleared cookie lines)")
|
||||
val cookies = android.webkit.CookieManager.getInstance()
|
||||
var cleared = 0
|
||||
for (url in COOKIE_URLS) {
|
||||
val lines = expiredCookies(cookies.getCookie(url), Uri.parse(url).host)
|
||||
for (line in lines) cookies.setCookie(url, line)
|
||||
cleared += lines.size
|
||||
}
|
||||
cookies.flush()
|
||||
Log.i(TAG, "cleared the Epic web sign-in ($cleared cookie lines)")
|
||||
}.onFailure { Log.w(TAG, "could not clear the Epic web sign-in: ${it.javaClass.simpleName}") }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun expiredCookies(header: String?, host: String? = null): List<String> =
|
||||
WebCookies.expired(header, COOKIE_DOMAIN, COOKIE_PATHS, host)
|
||||
internal fun expiredCookies(header: String?, host: String? = null): List<String> {
|
||||
val domains = listOfNotNull(COOKIE_DOMAIN, host?.takeIf { it.endsWith(COOKIE_DOMAIN) })
|
||||
return header.orEmpty().split(';').map { it.substringBefore('=').trim() }.filter { it.isNotEmpty() }.distinct()
|
||||
.flatMap { name ->
|
||||
COOKIE_PATHS.flatMap { path ->
|
||||
listOf("$name=; Max-Age=0; Path=$path; Secure") + domains.map { "$name=; Max-Age=0; Path=$path; Secure; Domain=$it" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun read(context: Context): Status {
|
||||
val app = context.applicationContext
|
||||
@@ -392,10 +404,10 @@ object EpicClient {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loginFlow(url: String, browserAgent: String): StoreLoginActivity.Flow? {
|
||||
internal fun loginFlow(url: String): StoreLoginActivity.Flow? {
|
||||
val redirect = redirectOf(url) ?: return null
|
||||
val agent = runCatching { EpicNative.call("auth.config")?.optString("webViewUserAgent") }.getOrNull()?.ifEmpty { null }
|
||||
return ClientLogin(url, redirect, agent ?: browserAgent)
|
||||
return ClientLogin(url, redirect, agent ?: StoreLoginActivity.CHROME_UA)
|
||||
}
|
||||
|
||||
private class ClientLogin(
|
||||
|
||||
@@ -20,8 +20,6 @@ object GogAuth {
|
||||
"&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=token&layout=client2"
|
||||
const val REDIRECT_PREFIX = "https://embed.gog.com/on_login_success"
|
||||
const val GALAXY_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) GOG Galaxy/2.0"
|
||||
const val COOKIE_DOMAIN = ".gog.com"
|
||||
val SIGN_IN_URLS = listOf("https://login.gog.com/", "https://auth.gog.com/")
|
||||
|
||||
fun authUrl(state: String): String = "$AUTH_URL&state=${Uri.encode(state)}"
|
||||
|
||||
|
||||
@@ -40,12 +40,6 @@ 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
|
||||
@@ -508,7 +502,7 @@ private fun ArtChooserDialog(
|
||||
sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER
|
||||
}
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(options != null) { focusWithinFrames({ false }) { first } }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
val apply: (AddedGameArt.Option) -> Unit = { option ->
|
||||
scope.launch {
|
||||
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
|
||||
@@ -630,23 +624,14 @@ private fun SgdbSearchDialog(
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { search() }
|
||||
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() } }) {
|
||||
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) {
|
||||
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 = { leaveField(); search() }),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
|
||||
)
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp
|
||||
@@ -658,24 +643,24 @@ private fun SgdbSearchDialog(
|
||||
val chosenGame = picked
|
||||
val list = matches.orEmpty()
|
||||
if (chosenGame == null) {
|
||||
list.forEachIndexed { i, match -> key(match.id) { PickerRow(null, match.name, null, focus = if (i == 0) firstItem else null) { leaveField(); showImages(match) } } }
|
||||
for (match in list) key(match.id) { PickerRow(null, match.name, null) { 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)) {
|
||||
images.forEachIndexed { i, option -> key(option.full) {
|
||||
for (option in images) key(option.full) {
|
||||
ArtOption(
|
||||
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = if (i == 0) firstItem else null,
|
||||
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = 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) { leaveField(); picked = null }
|
||||
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null }
|
||||
}
|
||||
if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
@@ -69,7 +69,6 @@ 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
|
||||
@@ -559,37 +558,13 @@ 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,
|
||||
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() })
|
||||
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)
|
||||
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 = m, onClick = { pending = AFTER_INSTALL; onInstall() },
|
||||
enabled = !busy, compact = true, modifier = modifier, onClick = onInstall,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -601,6 +576,9 @@ 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)
|
||||
@@ -608,7 +586,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(), onInstall, onUse, takeFocus = true)
|
||||
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ fun ComponentsPage(
|
||||
onImport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
games: List<Library.SteamGame> = emptyList(),
|
||||
initialGameKey: String? = null,
|
||||
generalFexPreset: String = "",
|
||||
onGeneralFexPreset: (String) -> Unit = {},
|
||||
requestInitialFocus: Boolean = true,
|
||||
@@ -170,7 +169,7 @@ fun ComponentsPage(
|
||||
val layer = if (comp in LAYERS) comp else GPU_TAB
|
||||
BackHandler(onBack = onBack)
|
||||
var scopeMenu by remember { mutableStateOf(false) }
|
||||
var selectedGameKey by remember(initialGameKey) { mutableStateOf(initialGameKey) }
|
||||
var selectedGameKey by remember { mutableStateOf<String?>(null) }
|
||||
var fexMenu by remember { mutableStateOf(false) }
|
||||
var gameProtonMenu by remember { mutableStateOf(false) }
|
||||
var gameProtonId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -13,12 +13,11 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
|
||||
@Composable
|
||||
fun CursorOverlay(
|
||||
positionOf: () -> Offset, visible: Boolean, scale: Float,
|
||||
position: Offset, visible: Boolean, scale: Float,
|
||||
image: ImageBitmap? = null, hotX: Int = 0, hotY: Int = 0, imageScale: Float = 1f,
|
||||
) {
|
||||
if (!visible) return
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val position = positionOf()
|
||||
if (image != null) {
|
||||
val w = (image.width * imageScale).toInt().coerceAtLeast(1)
|
||||
val h = (image.height * imageScale).toInt().coerceAtLeast(1)
|
||||
|
||||
@@ -359,21 +359,20 @@ private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) {
|
||||
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } }
|
||||
SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {}
|
||||
SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) {
|
||||
UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
|
||||
UninstallButton(stringResource(R.string.desktop_uninstall_title), stringResource(R.string.desktop_remove_text),
|
||||
enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) {
|
||||
internal fun UninstallButton(title: String, note: String, enabled: Boolean, modifier: Modifier = Modifier, onUninstall: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.common_uninstall)
|
||||
val title = stringResource(R.string.desktop_uninstall_title)
|
||||
val note = stringResource(R.string.desktop_remove_text)
|
||||
val keep = stringResource(R.string.flathub_keep)
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled, compact = true) {
|
||||
SecondaryButton(label, enabled = enabled, compact = true, modifier = modifier) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -31,7 +30,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -112,7 +110,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
if (onAdd != null) Rise(0) { GamesHeader(gameCount, s.steamGamesX64, onAdd, Modifier.padding(bottom = 10.dp)) }
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp), corner = { GameRemoveButton(current, s, a) }) {
|
||||
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
|
||||
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
|
||||
GameActions(current, s, a, onEdit, launch)
|
||||
}
|
||||
@@ -134,7 +132,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp), corner = { GameRemoveButton(current, s, a) }) {
|
||||
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
|
||||
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
|
||||
GameActions(current, s, a, onEdit, launch)
|
||||
}
|
||||
@@ -441,47 +439,6 @@ private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameRemoveButton(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
|
||||
val kind = com.droiddeck.launcher.frontend.GameRemoval.kind(g)
|
||||
if (s.shortcutPicker || kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER) return
|
||||
if (g.gameFiles?.isDirectory != true) return
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
var asking by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val shortcut = kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT
|
||||
val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall)
|
||||
val title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name)
|
||||
val body = when {
|
||||
shortcut -> stringResource(R.string.games_remove_body)
|
||||
g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body)
|
||||
else -> stringResource(R.string.games_uninstall_body)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.paneItem("game:remove").downToFirstTile().size(44.dp).clip(Shape12)
|
||||
.background(if (hot) colors.error.copy(alpha = 0.22f) else Color.Black.copy(alpha = 0.34f))
|
||||
.glideBorder(hot, Shape12, colors.error, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button) { asking = true }
|
||||
.controllerConfirm { asking = true }
|
||||
.semantics { contentDescription = label },
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
Icons.Outlined.Delete, contentDescription = null,
|
||||
tint = if (hot) colors.error else Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
if (asking) ConfirmDialog(
|
||||
title = title,
|
||||
text = body,
|
||||
confirm = label,
|
||||
onConfirm = { a.onGameRemoved(g) },
|
||||
onDismiss = { asking = false },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditGameButton(onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -613,7 +570,7 @@ private fun libraryLabel(g: Library.SteamGame): String = when {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composable BoxScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
|
||||
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
|
||||
val image = g.hero ?: g.art.takeIf { g.library != Library.EPIC_LAUNCHER }
|
||||
@@ -636,6 +593,5 @@ private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composab
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp),
|
||||
content = content,
|
||||
)
|
||||
Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner)
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,6 @@ class FrontEndActions(
|
||||
val onAddedGamesChanged: (folders: List<String>) -> Unit = {},
|
||||
val onSteamGameArtChanged: (appId: Int) -> Unit = {},
|
||||
val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> },
|
||||
val onGameRemoved: (Library.SteamGame) -> Unit = {},
|
||||
val onAddedGamesArt: (Boolean) -> Unit = {},
|
||||
val onSgdbKey: (String) -> Unit = {},
|
||||
val onStopGameFileSync: () -> Unit = {},
|
||||
@@ -189,7 +188,6 @@ class FrontEndActions(
|
||||
val onImportLossless: () -> Unit,
|
||||
val onProtons: () -> Unit,
|
||||
val onComponents: (focusContent: Boolean) -> Unit,
|
||||
val onGameComponents: (Library.SteamGame) -> Unit = { onComponents(true) },
|
||||
val onComponentsTab: (String) -> Unit = {},
|
||||
val onSaveImport: (Library.SteamGame) -> Unit = {},
|
||||
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
|
||||
|
||||
@@ -335,6 +335,9 @@ internal fun SetupPanel(
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) }
|
||||
SettingsGroup(stringResource(R.string.vr_module_title)) {
|
||||
VrModuleRows(uninstall = !s.sessionRunning && !s.runtimeActionsBlocked)
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
val controller = s.controller
|
||||
@@ -523,9 +526,8 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
val back = stringResource(R.string.hop_from_games)
|
||||
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
|
||||
val at = Hops.sources["card:components"]
|
||||
val open = { if (game == null) a.onComponents(true) else a.onGameComponents(game) }
|
||||
if (at == null) open()
|
||||
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open)
|
||||
if (at == null) a.onComponents(true)
|
||||
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
|
||||
}
|
||||
}
|
||||
add { m ->
|
||||
|
||||
@@ -185,9 +185,10 @@ internal fun PrimaryButton(
|
||||
.graphicsLayer { alpha = if (LaunchOrigin.flooding === placed) 0f else 1f; scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
|
||||
.clip(Shape12)
|
||||
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
|
||||
.shine(hot, 0.45f)
|
||||
.then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, Shape12))
|
||||
.then(if (hot) Modifier.border(2.dp, pal.signal, Shape12) else Modifier)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled) {
|
||||
.glideBorder(hot, Shape12, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
|
||||
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
|
||||
onClick()
|
||||
}
|
||||
|
||||
@@ -143,15 +143,15 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on
|
||||
}
|
||||
}
|
||||
otherId?.let { id ->
|
||||
val valid = id.isNotEmpty() && GameEnvironment.validScope(id)
|
||||
val open = { if (valid) { scope = id.toLong().toString(); otherId = null } }
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
AdbTextField(
|
||||
id, { otherId = it.filter(Char::isDigit) }, stringResource(R.string.game_env_app_id), KeyboardType.Number, ImeAction.Done,
|
||||
modifier = Modifier.weight(1f), compact = true,
|
||||
onDone = open,
|
||||
onDone = { if (GameEnvironment.validScope(id)) { scope = id.toLong().toString(); otherId = null } },
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.game_env_open), enabled = valid, onClick = open)
|
||||
SecondaryButton(stringResource(R.string.game_env_open), enabled = id.isNotEmpty() && GameEnvironment.validScope(id)) {
|
||||
scope = id.toLong().toString(); otherId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error) Small(stringResource(R.string.game_env_error), error = true)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.text.format.Formatter
|
||||
import android.speech.tts.TextToSpeech
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -72,7 +70,6 @@ import com.droiddeck.launcher.handtracking.Advice
|
||||
import com.droiddeck.launcher.handtracking.BodyPose
|
||||
import com.droiddeck.launcher.handtracking.HandCamera
|
||||
import com.droiddeck.launcher.handtracking.HandTracking
|
||||
import com.droiddeck.launcher.handtracking.TrackingPack
|
||||
import com.droiddeck.launcher.handtracking.Landmark
|
||||
import com.droiddeck.launcher.handtracking.TrackingCameras
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -189,27 +186,12 @@ internal fun HandCameraRows(host: MenuHost, track: (String) -> Modifier, inSessi
|
||||
})
|
||||
!s.permitted -> ActionRow(stringResource(R.string.hand_camera_access), stringResource(R.string.hand_camera_access_hint),
|
||||
stringResource(R.string.hand_camera_allow_button), { ask.launch(Manifest.permission.CAMERA) })
|
||||
!s.downloaded -> TrackingPackRow()
|
||||
!s.downloaded -> VrModuleRows(uninstall = null, track)
|
||||
!inSession -> ActionRow(stringResource(R.string.hand_camera_placement), stringResource(R.string.hand_camera_placement_hint),
|
||||
stringResource(R.string.hand_camera_check), { HandTracking.requestCheck() })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackingPackRow() {
|
||||
val context = LocalContext.current
|
||||
val pack by TrackingPack.state.collectAsState()
|
||||
val size = Formatter.formatShortFileSize(context, TrackingPack.size)
|
||||
when (val now = pack) {
|
||||
is AddonPack.State.Downloading -> ActionRow(stringResource(R.string.tracking_pack_title),
|
||||
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), {}, progress = now.progress)
|
||||
AddonPack.State.Failed -> ActionRow(stringResource(R.string.tracking_pack_title),
|
||||
stringResource(R.string.tracking_pack_failed), stringResource(R.string.tracking_pack_retry), { TrackingPack.ensure(context) })
|
||||
else -> ActionRow(stringResource(R.string.tracking_pack_title),
|
||||
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), { TrackingPack.ensure(context) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlacementCheck(settings: HandTracking.Settings, play: Boolean, onPlay: () -> Unit, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -3,9 +3,6 @@ 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
|
||||
@@ -108,7 +105,7 @@ private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) {
|
||||
private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { MutableInteractionSource() }
|
||||
@@ -116,7 +113,7 @@ private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, on
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(shape)
|
||||
modifier = 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)
|
||||
@@ -143,9 +140,6 @@ 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 ->
|
||||
@@ -178,7 +172,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, if (pin == startPin) first else null) { value = value.copy(pin = pin) }
|
||||
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin) { 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,7 +14,6 @@ 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
|
||||
@@ -99,7 +98,6 @@ 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,
|
||||
@@ -146,7 +144,6 @@ 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 = {},
|
||||
@@ -426,19 +423,6 @@ 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)
|
||||
@@ -545,35 +529,6 @@ 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
|
||||
|
||||
@@ -27,7 +27,6 @@ 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
|
||||
@@ -69,8 +68,6 @@ 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
|
||||
@@ -81,93 +78,21 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
|
||||
@Composable
|
||||
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))
|
||||
fun HudText(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
Text(
|
||||
text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.padding(12.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))),
|
||||
.background(Color(0x8C000000))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -79,6 +79,7 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -142,6 +143,8 @@ import com.droiddeck.launcher.gpu.SteamVr
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import kotlinx.coroutines.flow.collect
|
||||
@@ -195,11 +198,6 @@ 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,
|
||||
@@ -227,11 +225,6 @@ 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,
|
||||
@@ -318,6 +311,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
val pal = LocalPalette.current
|
||||
val host = rememberMenuHost()
|
||||
val (glasses, glassesSettings) = rememberGlasses()
|
||||
val vrPack by VrPack.state.collectAsState()
|
||||
val glassesPages = glassesTabs(glasses)
|
||||
var selectedGlassesTab by rememberSaveable { mutableStateOf(GlassesTab.DISPLAY) }
|
||||
val glassesTab = selectedGlassesTab.takeIf { key -> glassesPages.any { it.first == key } } ?: GlassesTab.DISPLAY
|
||||
@@ -472,30 +466,6 @@ 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) {
|
||||
@@ -520,7 +490,10 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.VR -> {
|
||||
SteamVrGroup(a) { key -> focus.track(page, key) }
|
||||
if (vrPack == AddonPack.State.Ready) SteamVrGroup(a) { key -> focus.track(page, key) }
|
||||
else SettingsGroup(stringResource(R.string.vr_module_title)) {
|
||||
VrModuleRows(uninstall = null) { key -> focus.track(page, key) }
|
||||
}
|
||||
if (!a.glassesOutput) CardboardGroup(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.CONTROLLER -> {
|
||||
@@ -1021,6 +994,8 @@ private fun SteamVrGroup(a: DrawerActions, track: (String) -> Modifier) {
|
||||
@Composable
|
||||
private fun SteamVrRow(a: DrawerActions) {
|
||||
val vrLabel = stringResource(R.string.cardboard_steamvr)
|
||||
val vrPack by VrPack.state.collectAsState()
|
||||
if (vrPack != AddonPack.State.Ready) return VrModuleRows(uninstall = null)
|
||||
when (a.steamVr) {
|
||||
SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, null, stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") })
|
||||
SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {}
|
||||
|
||||
@@ -29,7 +29,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import com.droiddeck.launcher.glasses.CalibrationDistance
|
||||
import com.droiddeck.launcher.handtracking.TrackingPack
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import com.droiddeck.launcher.glasses.Glasses
|
||||
import com.droiddeck.launcher.glasses.SpatialCalibrationProgress
|
||||
import com.droiddeck.launcher.glasses.SpatialTracking
|
||||
@@ -95,7 +95,7 @@ class SpatialTrackingActivity : ComponentActivity() {
|
||||
}
|
||||
override fun onPause() { SpatialTracking.diagnostics(false);super.onPause() }
|
||||
private fun printBoard() {
|
||||
if(!TrackingPack.loadNative(this)) return
|
||||
if(!VrPack.loadNative(this)) return
|
||||
val c=StereoCalibrator(this);val mat=Mat()
|
||||
c.board.generateImage(Size(1500.0,2100.0),mat,0,1)
|
||||
val bitmap=Bitmap.createBitmap(1500,2100,Bitmap.Config.ARGB_8888)
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@@ -50,6 +51,8 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.glasses.GlassesMenuMirror
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
@@ -107,6 +110,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
},
|
||||
)
|
||||
val (glasses, glassesSettings) = rememberGlasses()
|
||||
val vrPack by VrPack.state.collectAsState()
|
||||
val headset = glasses.present && glasses.canControl
|
||||
val drop = rememberDropSheetState()
|
||||
LaunchedEffect(headset) { if (!headset && (drop.open || drop.shown)) drop.reset() }
|
||||
@@ -117,7 +121,8 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
if (headset) DropSheet(
|
||||
drop, glassesTabs(glasses), covered = lifted,
|
||||
button = { enabled ->
|
||||
PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy && glasses.usable, large = true, icon = Icons.Filled.PlayArrow, fill = true,
|
||||
PrimaryButton(stringResource(if (vrPack == AddonPack.State.Ready) R.string.games_play_steamvr else R.string.games_install_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 ->
|
||||
|
||||
@@ -65,7 +65,7 @@ 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).controllerConfirm(onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,12 +201,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() {
|
||||
@@ -638,9 +638,8 @@ 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)
|
||||
.background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
|
||||
)
|
||||
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot),
|
||||
) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -56,7 +56,7 @@ object Themes {
|
||||
ELECTRIC, R.string.theme_electric, R.string.theme_electric_detail,
|
||||
background = Color(0xFF070A10), surface = Color(0xFF0F1522), surfaceVariant = Color(0xFF16203A), line = Color(0xFF1E2A47), line2 = Color(0xFF2A3A5E),
|
||||
onBackground = Color(0xFFEEF3FF), onSurfaceVariant = Color(0xFF8B9AB8),
|
||||
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
|
||||
primary = Color(0xFF2E86FF), primary2 = Color(0xFF136CE0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF2E86FF),
|
||||
good = Color(0xFF4CD37F),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(onDismiss,
|
||||
GestureDialogFrame(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_editor_subtitle), 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,6 +83,15 @@ 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,
|
||||
@@ -91,16 +100,9 @@ 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)
|
||||
GestureSection(R.string.gesture_tab_one, R.string.gesture_one_hint)
|
||||
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)) }
|
||||
}
|
||||
@@ -116,7 +118,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
GestureSection(R.string.gesture_tab_two)
|
||||
GestureSection(R.string.gesture_tab_two, R.string.gesture_two_hint)
|
||||
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)) }
|
||||
}
|
||||
@@ -132,7 +134,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
GestureSection(R.string.gesture_tab_three)
|
||||
GestureSection(R.string.gesture_tab_three, R.string.gesture_three_hint)
|
||||
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)) }
|
||||
}
|
||||
@@ -145,13 +147,16 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
GestureSection(R.string.gesture_tab_options)
|
||||
GestureSection(R.string.gesture_tab_options, R.string.gesture_options_hint)
|
||||
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)) }
|
||||
@@ -165,7 +170,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GestureDialogFrame(onDismiss: () -> Unit,
|
||||
private fun GestureDialogFrame(title: String, subtitle: String, 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.
|
||||
@@ -176,8 +181,15 @@ private fun GestureDialogFrame(onDismiss: () -> Unit,
|
||||
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(end = 4.dp), horizontalArrangement = Arrangement.End) {
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) }
|
||||
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)) }
|
||||
}
|
||||
content()
|
||||
HorizontalDivider(color = p.line)
|
||||
@@ -190,8 +202,11 @@ private fun GestureDialogFrame(onDismiss: () -> Unit,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GestureSection(@StringRes title: Int) {
|
||||
Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -227,18 +242,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, onDismiss = { open = false }) { open = false; onChoose(it) }
|
||||
if (open) GestureActionPicker(choices, selected, stringResource(gesture), onDismiss = { open = false }) { open = false; onChoose(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GestureActionPicker(choices: List<String>, selected: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
|
||||
internal fun GestureActionPicker(choices: List<String>, selected: String, gesture: 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(onDismiss) {
|
||||
GestureDialogFrame(stringResource(R.string.gesture_action), stringResource(R.string.gesture_action_for, gesture), 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 = {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.VrPack
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal fun VrModuleRows(uninstall: Boolean?, track: (String) -> Modifier = { Modifier }) {
|
||||
val context = LocalContext.current
|
||||
val pack by VrPack.state.collectAsState()
|
||||
val removal by VrPack.removal.collectAsState()
|
||||
val title = stringResource(R.string.vr_module_title)
|
||||
val now = pack
|
||||
when {
|
||||
removal.busy -> {
|
||||
SettingsRow(title, stringResource(R.string.vr_module_removing)) {}
|
||||
Box(Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 10.dp)) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
}
|
||||
now is AddonPack.State.Downloading -> {
|
||||
val percent = (now.progress * 100).toInt()
|
||||
SettingsRow(title, stringResource(R.string.setup_runtime_progress, stringResource(R.string.vr_module_downloading), percent)) {}
|
||||
Box(Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 10.dp)) {
|
||||
LinearProgressIndicator(progress = { now.progress }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
}
|
||||
now == AddonPack.State.Ready -> {
|
||||
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { VrPack.installedBytes(context) } }
|
||||
SettingsRow(title, stringResource(R.string.vr_module_installed, size?.let { FileUtils.sizeToString(it) } ?: "…")) {
|
||||
if (uninstall != null) UninstallButton(stringResource(R.string.vr_module_uninstall_title), stringResource(R.string.vr_module_uninstall_text),
|
||||
enabled = uninstall, modifier = Modifier.testTag("vr-module-uninstall")) { VrPack.uninstall(context) }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val hint = if (now == AddonPack.State.Failed) stringResource(R.string.vr_module_failed)
|
||||
else stringResource(R.string.vr_module_hint, FileUtils.sizeToString(VrPack.size))
|
||||
SettingsRow(title, hint) {
|
||||
PrimaryButton(stringResource(R.string.common_download), compact = true,
|
||||
modifier = track("vr-module").testTag("vr-module-download")) { VrPack.ensure(context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
removal.error?.let { Box(Modifier.padding(horizontal = 14.dp, vertical = 6.dp)) { Note(it) } }
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s عبر FEX</string>
|
||||
<string name="app_fex_not_ready">هذا البرنامج مبني لأجهزة كمبيوتر %1$s ويعمل عبر FEX، الذي لم يتم إعداده بعد. افتح Steam مرة واحدة أثناء تسجيل الدخول: سيجعله DroidDeck يُنزّل FEX وSteam Linux Runtime اللازمة، ثم يُفتح هذا البرنامج.</string>
|
||||
<string name="session_end_program">هذا البرنامج</string>
|
||||
<string name="session_end_steam_download">تعذّر تنزيل عميل Steam. قد تكون شبكتك أو VPN تحظر خوادم Steam؛ تحقّق من الاتصال وحاول مرة أخرى.</string>
|
||||
<string name="session_end_steam_setup">تعذّر إعداد عميل Steam. تحقّق من الاتصال والمساحة الفارغة، ثم حاول مرة أخرى.</string>
|
||||
<string name="session_end_fex_missing">%1$s مبني لأجهزة كمبيوتر x86 ويعمل عبر FEX، الذي لم يتم تثبيته بعد. شغّل Steam مرة واحدة أثناء تسجيل الدخول ودعه يُنزّل FEX وSteam Linux Runtime، ثم شغّل هذا البرنامج مجددًا.</string>
|
||||
<string name="add_app_flatpak_hint">معرّف تطبيق على Flathub أو صفحته على Flathub. يُعَدّ Flatpak أولًا إن لم يكن مُعدًّا بعد.</string>
|
||||
<string name="add_app_file">الملف</string>
|
||||
@@ -590,16 +588,6 @@
|
||||
<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>
|
||||
@@ -1048,6 +1036,7 @@
|
||||
<string name="games_empty">الألعاب التي تثبّتها في Steam تظهر هنا بعد الجلسة.</string>
|
||||
<string name="games_play_steam">تشغيل Steam</string>
|
||||
<string name="games_play_steamvr">تشغيل SteamVR</string>
|
||||
<string name="games_install_play_steamvr">تنزيل وتشغيل SteamVR</string>
|
||||
<string name="games_launch_settings">إعدادات التشغيل</string>
|
||||
<string name="games_launch">تشغيل</string>
|
||||
<string name="games_files">ملفات اللعبة</string>
|
||||
@@ -1074,9 +1063,6 @@
|
||||
<string name="games_added">لعبة مُضافة</string>
|
||||
<string name="games_custom">لعبة مخصّصة</string>
|
||||
<string name="games_store_storage">تخزين الألعاب</string>
|
||||
<string name="games_uninstall_body">يحذف ملفاتها ويزيلها من Steam.</string>
|
||||
<string name="games_remove_title">إزالة %1$s؟</string>
|
||||
<string name="games_remove_body">يحذف الاختصار وتبقى ملفات اللعبة.</string>
|
||||
<string name="gpu_auto_hint">أفضل زوج لهذا GPU، يُحافَظ على تحديثه</string>
|
||||
<string name="gpu_manual_hint">اختر زوجًا بنفسك</string>
|
||||
<string name="gpu_checking">جارٍ البحث عن برامج التشغيل…</string>
|
||||
@@ -1409,9 +1395,12 @@
|
||||
<string name="session_proton_downloading_mb">جارٍ تنزيل Proton Experimental (ARM64) · %1$d من %2$d MB</string>
|
||||
<string name="session_proton_checking">جارٍ فحص Proton Experimental (ARM64)</string>
|
||||
<string name="session_proton_unpacking">جارٍ فك ضغط Proton Experimental (ARM64)</string>
|
||||
<string name="session_vr_downloading">جارٍ تنزيل وحدة الواقع الافتراضي</string>
|
||||
<string name="session_vr_downloading_mb">جارٍ تنزيل وحدة الواقع الافتراضي · %1$d من %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">تعذّر الوصول إلى كتالوج بيئة التشغيل. تحقَّق من الاتصال واضغط على «تشغيل» مرة أخرى.</string>
|
||||
<string name="session_runtime_install_failed">لم يتم تثبيت بيئة تشغيل Linux. تحقَّق من بيئة التشغيل في «الإعداد» واضغط على «تشغيل» مرة أخرى.</string>
|
||||
<string name="session_desktop_install_failed">لم يتم تثبيت سطح المكتب (%1$s). تحقَّق من الاتصال واضغط على «سطح المكتب» مرة أخرى.</string>
|
||||
<string name="session_vr_install_failed">لم يتم تثبيت وحدة الواقع الافتراضي. تحقَّق من الاتصال واضغط على «تشغيل SteamVR» مرة أخرى.</string>
|
||||
<string name="session_ended_named">توقّف %1$s بشكل غير متوقَّع. شارِك السجلات مع بلاغ عن الخطأ، أو حاوِل مرة أخرى.</string>
|
||||
<string name="session_ended_program">توقّف البرنامج بشكل غير متوقَّع. شارِك السجلات مع بلاغ عن الخطأ، أو حاوِل مرة أخرى.</string>
|
||||
<string name="session_ended_desktop">توقّف سطح المكتب بشكل غير متوقَّع. شارِك السجلات مع بلاغ عن الخطأ، أو حاوِل مرة أخرى.</string>
|
||||
@@ -2566,6 +2555,16 @@
|
||||
<string name="hand_camera_placement">وضع الكاميرا</string>
|
||||
<string name="hand_camera_placement_hint">تحقّق من أن الكاميرا تراك قبل اللعب.</string>
|
||||
<string name="hand_camera_check">تحقّق</string>
|
||||
<string name="vr_module_title">وحدة الواقع الافتراضي</string>
|
||||
<string name="vr_module_hint">SteamVR على النظارات وQuest وCardboard، مع تتبع اليدين بالكاميرا · تنزيل %1$s</string>
|
||||
<string name="vr_module_installed">مثبّتة · %1$s</string>
|
||||
<string name="vr_module_downloading">جارٍ تنزيل وحدة الواقع الافتراضي</string>
|
||||
<string name="vr_module_failed">تعذّر تنزيل وحدة الواقع الافتراضي. تحقَّق من الاتصال وحاول مرة أخرى.</string>
|
||||
<string name="vr_module_removing">جارٍ إزالة وحدة الواقع الافتراضي</string>
|
||||
<string name="vr_module_busy">أوقف الجلسة قبل إزالة وحدة الواقع الافتراضي.</string>
|
||||
<string name="vr_module_remove_failed">تعذّرت إزالة وحدة الواقع الافتراضي</string>
|
||||
<string name="vr_module_uninstall_title">إلغاء تثبيت وحدة الواقع الافتراضي؟</string>
|
||||
<string name="vr_module_uninstall_text">سيتوقف SteamVR وتتبع اليدين بالكاميرا عن العمل حتى تنزّلها مجددًا. يبقى SteamVR نفسه والألعاب والإعدادات كما هي.</string>
|
||||
<string name="hand_camera_check_title">وضع الكاميرا</string>
|
||||
<string name="hand_camera_how_rear">ضع الجهاز واقفًا على بُعد 1.5 إلى 2 متر تقريبًا بارتفاع الصدر، مع توجيه الكاميرا الخلفية نحوك. ثم ادخل إلى مجال الرؤية ويداك ممدودتان.</string>
|
||||
<string name="hand_camera_how_front">ضع الجهاز واقفًا على بُعد 1.5 إلى 2 متر تقريبًا بارتفاع الصدر، مع توجيه الشاشة نحوك. ثم ادخل إلى مجال الرؤية ويداك ممدودتان.</string>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s con FEX</string>
|
||||
<string name="app_fex_not_ready">Este programa está hecho para PC %1$s y se ejecuta con FEX, que aún no está listo. Abre Steam una vez con tu sesión iniciada: DroidDeck hará que descargue FEX y el Steam Linux Runtime que necesita, y después se abrirá.</string>
|
||||
<string name="session_end_program">Este programa</string>
|
||||
<string name="session_end_steam_download">No se pudo descargar el cliente de Steam. Es posible que tu red o VPN esté bloqueando los servidores de Steam; revisa la conexión e inténtalo de nuevo.</string>
|
||||
<string name="session_end_steam_setup">No se pudo configurar el cliente de Steam. Revisa la conexión y el espacio libre, y vuelve a intentarlo.</string>
|
||||
<string name="session_end_fex_missing">%1$s está hecho para PC x86 y se ejecuta con FEX, que aún no está instalado. Abre Steam una vez con tu sesión iniciada y deja que descargue FEX y el Steam Linux Runtime; después vuelve a iniciarlo.</string>
|
||||
<string name="add_app_flatpak_hint">Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está.</string>
|
||||
<string name="add_app_file">Archivo</string>
|
||||
@@ -577,16 +575,6 @@
|
||||
<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>
|
||||
@@ -1009,6 +997,16 @@
|
||||
<string name="hand_camera_placement">Posición de la cámara</string>
|
||||
<string name="hand_camera_placement_hint">Comprueba que la cámara pueda verte antes de jugar.</string>
|
||||
<string name="hand_camera_check">Comprobar</string>
|
||||
<string name="vr_module_title">Módulo de RV</string>
|
||||
<string name="vr_module_hint">SteamVR en lentes, Quest y Cardboard, además de seguimiento de manos con la cámara · descarga de %1$s</string>
|
||||
<string name="vr_module_installed">Instalado · %1$s</string>
|
||||
<string name="vr_module_downloading">Descargando el módulo de RV</string>
|
||||
<string name="vr_module_failed">No se pudo descargar el módulo de RV. Revisa la conexión y vuelve a intentarlo.</string>
|
||||
<string name="vr_module_removing">Quitando el módulo de RV</string>
|
||||
<string name="vr_module_busy">Detén la sesión antes de quitar el módulo de RV.</string>
|
||||
<string name="vr_module_remove_failed">No se pudo quitar el módulo de RV</string>
|
||||
<string name="vr_module_uninstall_title">¿Desinstalar el módulo de RV?</string>
|
||||
<string name="vr_module_uninstall_text">SteamVR y el seguimiento de manos con la cámara dejarán de funcionar hasta que lo vuelvas a descargar. SteamVR, los juegos y la configuración se conservan.</string>
|
||||
<string name="hand_camera_check_title">Posición de la cámara</string>
|
||||
<string name="hand_camera_how_rear">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la cámara trasera hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
|
||||
<string name="hand_camera_how_front">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la pantalla hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
|
||||
@@ -1086,6 +1084,7 @@
|
||||
<string name="games_empty">Los juegos que instales en Steam aparecen aquí después de la sesión.</string>
|
||||
<string name="games_play_steam">Jugar en Steam</string>
|
||||
<string name="games_play_steamvr">Jugar en SteamVR</string>
|
||||
<string name="games_install_play_steamvr">Descargar y jugar en SteamVR</string>
|
||||
<string name="games_launch_settings">Ajustes de inicio</string>
|
||||
<string name="games_launch">Iniciar</string>
|
||||
<string name="games_files">Archivos del juego</string>
|
||||
@@ -1112,9 +1111,6 @@
|
||||
<string name="games_added">Juego agregado</string>
|
||||
<string name="games_custom">Juego personalizado</string>
|
||||
<string name="games_store_storage">Almacenamiento de juegos</string>
|
||||
<string name="games_uninstall_body">Elimina sus archivos y la quita de Steam.</string>
|
||||
<string name="games_remove_title">¿Quitar %1$s?</string>
|
||||
<string name="games_remove_body">Elimina su acceso directo. Los archivos del juego se conservan.</string>
|
||||
<string name="gpu_auto_hint">El mejor par para esta GPU, siempre actualizado</string>
|
||||
<string name="gpu_manual_hint">Elige un par manualmente</string>
|
||||
<string name="gpu_checking">Buscando controladores…</string>
|
||||
@@ -1421,9 +1417,12 @@
|
||||
<string name="session_proton_downloading_mb">descargando Proton Experimental (ARM64) · %1$d de %2$d MB</string>
|
||||
<string name="session_proton_checking">comprobando Proton Experimental (ARM64)</string>
|
||||
<string name="session_proton_unpacking">descomprimiendo Proton Experimental (ARM64)</string>
|
||||
<string name="session_vr_downloading">descargando el módulo de RV</string>
|
||||
<string name="session_vr_downloading_mb">descargando el módulo de RV · %1$d de %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">No se pudo acceder al catálogo del runtime. Revisa la conexión y vuelve a presionar Jugar.</string>
|
||||
<string name="session_runtime_install_failed">El runtime de Linux no se instaló. Revisa el runtime en Configuración y vuelve a presionar Jugar.</string>
|
||||
<string name="session_desktop_install_failed">El escritorio no se instaló (%1$s). Revisa la conexión y presiona Escritorio de nuevo.</string>
|
||||
<string name="session_vr_install_failed">El módulo de RV no se instaló. Revisa la conexión y presiona Jugar en SteamVR de nuevo.</string>
|
||||
<string name="session_ended_named">%1$s se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
|
||||
<string name="session_ended_program">El programa se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
|
||||
<string name="session_ended_desktop">El escritorio se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s през FEX</string>
|
||||
<string name="app_fex_not_ready">Тази програма е компилирана за %1$s компютри и се стартира през FEX, който още не е настроен. Отворете Steam веднъж, докато сте влезли: DroidDeck ще изтегли FEX и необходимата Steam Linux Runtime, след което тя ще се отвори.</string>
|
||||
<string name="session_end_program">Тази програма</string>
|
||||
<string name="session_end_steam_download">Клиентът на Steam не можа да бъде изтеглен. Мрежата или VPN връзката ви може да блокира сървърите на Steam; проверете връзката и опитайте отново.</string>
|
||||
<string name="session_end_steam_setup">Клиентът на Steam не можа да бъде настроен. Проверете връзката и свободното място, след което опитайте отново.</string>
|
||||
<string name="session_end_fex_missing">%1$s е компилирана за x86 компютри и се стартира през FEX, който още не е инсталиран. Стартирайте Steam веднъж, докато сте влезли, и го оставете да изтегли FEX и Steam Linux Runtime, след което стартирайте отново.</string>
|
||||
<string name="add_app_flatpak_hint">ID на приложение в Flathub или страницата му в Flathub. Flatpak се настройва първо, ако още не е.</string>
|
||||
<string name="add_app_file">Файл</string>
|
||||
@@ -582,16 +580,6 @@
|
||||
<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>
|
||||
@@ -1024,6 +1012,7 @@
|
||||
<string name="games_empty">Игрите, които инсталирате в Steam, се показват тук след сесията.</string>
|
||||
<string name="games_play_steam">Пускане на Steam</string>
|
||||
<string name="games_play_steamvr">Пускане на SteamVR</string>
|
||||
<string name="games_install_play_steamvr">Изтегли и пусни SteamVR</string>
|
||||
<string name="games_launch_settings">Настройки за стартиране</string>
|
||||
<string name="games_launch">Стартиране</string>
|
||||
<string name="games_files">Файлове на играта</string>
|
||||
@@ -1050,9 +1039,6 @@
|
||||
<string name="games_added">Добавена игра</string>
|
||||
<string name="games_custom">Персонализирана игра</string>
|
||||
<string name="games_store_storage">Хранилище за игри</string>
|
||||
<string name="games_uninstall_body">Изтрива файловете ѝ и я премахва от Steam.</string>
|
||||
<string name="games_remove_title">Премахване на %1$s?</string>
|
||||
<string name="games_remove_body">Изтрива пряката пътека. Файловете на играта остават.</string>
|
||||
<string name="gpu_auto_hint">Най-добрата двойка за този GPU, поддържана актуална</string>
|
||||
<string name="gpu_manual_hint">Изберете двойка сами</string>
|
||||
<string name="gpu_checking">Проверка за драйвери…</string>
|
||||
@@ -1357,9 +1343,12 @@
|
||||
<string name="session_proton_downloading_mb">изтегляне на Proton Experimental (ARM64) · %1$d от %2$d MB</string>
|
||||
<string name="session_proton_checking">проверка на Proton Experimental (ARM64)</string>
|
||||
<string name="session_proton_unpacking">разопаковане на Proton Experimental (ARM64)</string>
|
||||
<string name="session_vr_downloading">изтегляне на VR модула</string>
|
||||
<string name="session_vr_downloading_mb">изтегляне на VR модула · %1$d от %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">Каталогът на средата не можа да бъде достигнат. Провери връзката и натисни „Пускане“ отново.</string>
|
||||
<string name="session_runtime_install_failed">Средата на Linux не се инсталира. Провери средата в „Настройка“ и натисни „Пускане“ отново.</string>
|
||||
<string name="session_desktop_install_failed">Работният плот не се инсталира (%1$s). Провери връзката и натисни „Desktop“ отново.</string>
|
||||
<string name="session_vr_install_failed">VR модулът не се инсталира. Провери връзката и натисни „Пускане на SteamVR“ отново.</string>
|
||||
<string name="session_ended_named">%1$s спря неочаквано. Сподели логовете в отчет за грешка или опитай отново.</string>
|
||||
<string name="session_ended_program">Програмата спря неочаквано. Сподели логовете в отчет за грешка или опитай отново.</string>
|
||||
<string name="session_ended_desktop">Работният плот спря неочаквано. Сподели логовете в отчет за грешка или опитай отново.</string>
|
||||
@@ -2466,6 +2455,16 @@
|
||||
<string name="hand_camera_placement">Разположение на камерата</string>
|
||||
<string name="hand_camera_placement_hint">Проверете дали камерата ви вижда, преди да играете.</string>
|
||||
<string name="hand_camera_check">Проверка</string>
|
||||
<string name="vr_module_title">VR модул</string>
|
||||
<string name="vr_module_hint">SteamVR на очила, Quest и Cardboard, плюс проследяване на ръцете с камерата · изтегляне %1$s</string>
|
||||
<string name="vr_module_installed">Инсталиран · %1$s</string>
|
||||
<string name="vr_module_downloading">Изтегляне на VR модула</string>
|
||||
<string name="vr_module_failed">VR модулът не можа да бъде изтеглен. Провери връзката и опитай отново.</string>
|
||||
<string name="vr_module_removing">Премахване на VR модула</string>
|
||||
<string name="vr_module_busy">Спри сесията, преди да премахнеш VR модула.</string>
|
||||
<string name="vr_module_remove_failed">VR модулът не можа да бъде премахнат</string>
|
||||
<string name="vr_module_uninstall_title">Деинсталиране на VR модула?</string>
|
||||
<string name="vr_module_uninstall_text">SteamVR и проследяването на ръцете с камерата спират да работят, докато не го изтеглиш отново. Самият SteamVR, игрите и настройките се запазват.</string>
|
||||
<string name="hand_camera_check_title">Разположение на камерата</string>
|
||||
<string name="hand_camera_how_rear">Поставете устройството изправено на 1,5–2 метра разстояние, приблизително на нивото на гърдите, със задната камера, насочена към вас. След това застанете в кадър с протегнати ръце.</string>
|
||||
<string name="hand_camera_how_front">Поставете устройството изправено на 1,5–2 метра разстояние, приблизително на нивото на гърдите, с екрана, насочен към вас. След това застанете в кадър с протегнати ръце.</string>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s přes FEX</string>
|
||||
<string name="app_fex_not_ready">Tento program je sestaven pro PC %1$s a běží přes FEX, který ještě není nastaven. Jednou spusťte přihlášený Steam: DroidDeck tím stáhne FEX a Steam Linux Runtime, který potřebuje, a pak se tento program otevře.</string>
|
||||
<string name="session_end_program">Tento program</string>
|
||||
<string name="session_end_steam_download">Klienta Steam se nepodařilo stáhnout. Vaše síť nebo VPN možná blokuje servery Steamu; zkontrolujte připojení a zkuste to znovu.</string>
|
||||
<string name="session_end_steam_setup">Klienta Steam se nepodařilo nastavit. Zkontrolujte připojení a volné místo a zkuste to znovu.</string>
|
||||
<string name="session_end_fex_missing">%1$s je sestaven pro PC x86 a běží přes FEX, který ještě není nainstalován. Jednou spusťte přihlášený Steam a nechte ho stáhnout FEX a Steam Linux Runtime, pak to spusťte znovu.</string>
|
||||
<string name="add_app_flatpak_hint">ID aplikace Flathub nebo její stránka na Flathub. Flatpak se nejprve nastaví, pokud ještě není.</string>
|
||||
<string name="add_app_file">Soubor</string>
|
||||
@@ -586,16 +584,6 @@
|
||||
<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>
|
||||
@@ -1036,6 +1024,7 @@
|
||||
<string name="games_empty">Hry, které nainstalujete ve Steamu, se zde zobrazí po relaci.</string>
|
||||
<string name="games_play_steam">Hrát ve Steamu</string>
|
||||
<string name="games_play_steamvr">Hrát ve SteamVR</string>
|
||||
<string name="games_install_play_steamvr">Stáhnout a hrát ve SteamVR</string>
|
||||
<string name="games_launch_settings">Nastavení spuštění</string>
|
||||
<string name="games_launch">Spustit</string>
|
||||
<string name="games_files">Soubory hry</string>
|
||||
@@ -1062,9 +1051,6 @@
|
||||
<string name="games_added">Přidaná hra</string>
|
||||
<string name="games_custom">Vlastní hra</string>
|
||||
<string name="games_store_storage">Úložiště her</string>
|
||||
<string name="games_uninstall_body">Smaže její soubory a odebere ji ze Steamu.</string>
|
||||
<string name="games_remove_title">Odebrat %1$s?</string>
|
||||
<string name="games_remove_body">Smaže zástupce. Soubory hry zůstanou.</string>
|
||||
<string name="gpu_auto_hint">Nejlepší dvojice pro tento GPU, udržovaná aktuální</string>
|
||||
<string name="gpu_manual_hint">Vyberte dvojici sami</string>
|
||||
<string name="gpu_checking">Hledání ovladačů…</string>
|
||||
@@ -1383,9 +1369,12 @@
|
||||
<string name="session_proton_downloading_mb">stahování Proton Experimental (ARM64) · %1$d z %2$d MB</string>
|
||||
<string name="session_proton_checking">kontrola Proton Experimental (ARM64)</string>
|
||||
<string name="session_proton_unpacking">rozbalování Proton Experimental (ARM64)</string>
|
||||
<string name="session_vr_downloading">stahování modulu VR</string>
|
||||
<string name="session_vr_downloading_mb">stahování modulu VR · %1$d z %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">Nepodařilo se spojit s katalogem runtime. Zkontrolujte připojení a znovu stiskněte Hrát.</string>
|
||||
<string name="session_runtime_install_failed">Runtime Linuxu se nenainstaloval. Zkontrolujte runtime v Přípravě a znovu stiskněte Hrát.</string>
|
||||
<string name="session_desktop_install_failed">Plocha se nenainstalovala (%1$s). Zkontrolujte připojení a znovu stiskněte Plocha.</string>
|
||||
<string name="session_vr_install_failed">Modul VR se nenainstaloval. Zkontrolujte připojení a znovu stiskněte Hrát ve SteamVR.</string>
|
||||
<string name="session_ended_named">Došlo k neočekávanému zastavení %1$s. Přiložte logy k hlášení chyby, nebo zkuste znovu.</string>
|
||||
<string name="session_ended_program">Program neočekávaně skončil. Přiložte logy k hlášení chyby, nebo zkuste znovu.</string>
|
||||
<string name="session_ended_desktop">Plocha neočekávaně skončila. Přiložte logy k hlášení chyby, nebo zkuste znovu.</string>
|
||||
@@ -2516,6 +2505,16 @@
|
||||
<string name="hand_camera_placement">Umístění kamery</string>
|
||||
<string name="hand_camera_placement_hint">Než začnete hrát, zkontrolujte, že vás kamera vidí.</string>
|
||||
<string name="hand_camera_check">Zkontrolovat</string>
|
||||
<string name="vr_module_title">Modul VR</string>
|
||||
<string name="vr_module_hint">SteamVR na brýlích, Quest a Cardboard a sledování rukou kamerou · stažení %1$s</string>
|
||||
<string name="vr_module_installed">Nainstalováno · %1$s</string>
|
||||
<string name="vr_module_downloading">Stahování modulu VR</string>
|
||||
<string name="vr_module_failed">Modul VR se nepodařilo stáhnout. Zkontrolujte připojení a zkuste to znovu.</string>
|
||||
<string name="vr_module_removing">Odebírání modulu VR</string>
|
||||
<string name="vr_module_busy">Před odebráním modulu VR ukončete relaci.</string>
|
||||
<string name="vr_module_remove_failed">Modul VR se nepodařilo odebrat</string>
|
||||
<string name="vr_module_uninstall_title">Odinstalovat modul VR?</string>
|
||||
<string name="vr_module_uninstall_text">SteamVR a sledování rukou kamerou přestanou fungovat, dokud modul znovu nestáhnete. SteamVR samotný, hry a nastavení zůstanou zachovány.</string>
|
||||
<string name="hand_camera_check_title">Umístění kamery</string>
|
||||
<string name="hand_camera_how_rear">Postavte zařízení svisle do vzdálenosti 1,5 až 2 metry přibližně ve výšce hrudníku tak, aby zadní kamera směřovala k vám. Pak se postavte do záběru s nataženýma rukama.</string>
|
||||
<string name="hand_camera_how_front">Postavte zařízení svisle do vzdálenosti 1,5 až 2 metry přibližně ve výšce hrudníku tak, aby obrazovka směřovala k vám. Pak se postavte do záběru s nataženýma rukama.</string>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s via FEX</string>
|
||||
<string name="app_fex_not_ready">Dette program er bygget til %1$s-pc’er og køres via FEX, som ikke er sat op endnu. Åbn Steam én gang, mens du er logget ind: DroidDeck downloader så FEX og den Steam Linux Runtime, det kræver, hvorefter dette åbner.</string>
|
||||
<string name="session_end_program">Dette program</string>
|
||||
<string name="session_end_steam_download">Steam-klienten kunne ikke downloades. Dit netværk eller din VPN blokerer muligvis Steams servere; tjek forbindelsen, og prøv igen.</string>
|
||||
<string name="session_end_steam_setup">Steam-klienten kunne ikke konfigureres. Tjek forbindelsen og den ledige lagerplads, og prøv igen.</string>
|
||||
<string name="session_end_fex_missing">%1$s er bygget til x86-pc’er og køres via FEX, som ikke er installeret endnu. Start Steam én gang, mens du er logget ind, og lad det downloade FEX og Steam Linux Runtime, og start så dette igen.</string>
|
||||
<string name="add_app_flatpak_hint">Et Flathub-app-id eller dens Flathub-side. Flatpak sættes op først, hvis det ikke allerede er.</string>
|
||||
<string name="add_app_file">Fil</string>
|
||||
@@ -582,16 +580,6 @@
|
||||
<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>
|
||||
@@ -1024,6 +1012,7 @@
|
||||
<string name="games_empty">Spil, du installerer i Steam, vises her efter sessionen.</string>
|
||||
<string name="games_play_steam">Spil Steam</string>
|
||||
<string name="games_play_steamvr">Spil SteamVR</string>
|
||||
<string name="games_install_play_steamvr">Download og spil SteamVR</string>
|
||||
<string name="games_launch_settings">Startindstillinger</string>
|
||||
<string name="games_launch">Start</string>
|
||||
<string name="games_files">Spilfiler</string>
|
||||
@@ -1050,9 +1039,6 @@
|
||||
<string name="games_added">Tilføjet spil</string>
|
||||
<string name="games_custom">Brugerdefineret spil</string>
|
||||
<string name="games_store_storage">Spillager</string>
|
||||
<string name="games_uninstall_body">Sletter dets filer og fjerner det fra Steam.</string>
|
||||
<string name="games_remove_title">Fjern %1$s?</string>
|
||||
<string name="games_remove_body">Sletter genvejen. Spillets filer bliver.</string>
|
||||
<string name="gpu_auto_hint">Det bedste par til denne GPU, holdt opdateret</string>
|
||||
<string name="gpu_manual_hint">Vælg et par selv</string>
|
||||
<string name="gpu_checking">Søger efter drivere…</string>
|
||||
@@ -1357,9 +1343,12 @@
|
||||
<string name="session_proton_downloading_mb">downloader Proton Experimental (ARM64) · %1$d af %2$d MB</string>
|
||||
<string name="session_proton_checking">kontrollerer Proton Experimental (ARM64)</string>
|
||||
<string name="session_proton_unpacking">udpakker Proton Experimental (ARM64)</string>
|
||||
<string name="session_vr_downloading">downloader VR-modulet</string>
|
||||
<string name="session_vr_downloading_mb">downloader VR-modulet · %1$d af %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">Kunne ikke nå runtime-kataloget. Kontroller forbindelsen, og tryk på Spil igen.</string>
|
||||
<string name="session_runtime_install_failed">Linux-runtime blev ikke installeret. Kontroller runtime i Opsætning, og tryk på Spil igen.</string>
|
||||
<string name="session_desktop_install_failed">Skrivebordet blev ikke installeret (%1$s). Kontroller forbindelsen, og tryk på Skrivebord igen.</string>
|
||||
<string name="session_vr_install_failed">VR-modulet blev ikke installeret. Kontroller forbindelsen, og tryk på Spil SteamVR igen.</string>
|
||||
<string name="session_ended_named">%1$s stoppede uventet. Del logs med en fejlrapport, eller prøv igen.</string>
|
||||
<string name="session_ended_program">Programmet stoppede uventet. Del logs med en fejlrapport, eller prøv igen.</string>
|
||||
<string name="session_ended_desktop">Skrivebordet stoppede uventet. Del logs med en fejlrapport, eller prøv igen.</string>
|
||||
@@ -2466,6 +2455,16 @@
|
||||
<string name="hand_camera_placement">Kameraplacering</string>
|
||||
<string name="hand_camera_placement_hint">Tjek, at kameraet kan se dig, før du spiller.</string>
|
||||
<string name="hand_camera_check">Tjek</string>
|
||||
<string name="vr_module_title">VR-modul</string>
|
||||
<string name="vr_module_hint">SteamVR på briller, Quest og Cardboard samt håndsporing med kameraet · %1$s download</string>
|
||||
<string name="vr_module_installed">Installeret · %1$s</string>
|
||||
<string name="vr_module_downloading">Downloader VR-modulet</string>
|
||||
<string name="vr_module_failed">VR-modulet kunne ikke downloades. Kontroller forbindelsen, og prøv igen.</string>
|
||||
<string name="vr_module_removing">Fjerner VR-modulet</string>
|
||||
<string name="vr_module_busy">Stop sessionen, før du fjerner VR-modulet.</string>
|
||||
<string name="vr_module_remove_failed">VR-modulet kunne ikke fjernes</string>
|
||||
<string name="vr_module_uninstall_title">Afinstaller VR-modulet?</string>
|
||||
<string name="vr_module_uninstall_text">SteamVR og håndsporing med kameraet holder op med at virke, indtil du downloader det igen. Selve SteamVR, spil og indstillinger bevares.</string>
|
||||
<string name="hand_camera_check_title">Kameraplacering</string>
|
||||
<string name="hand_camera_how_rear">Stil enheden op 1,5 til 2 meter væk i cirka brysthøjde med bagkameraet vendt mod dig. Træd så ind i billedet med hænderne ude.</string>
|
||||
<string name="hand_camera_how_front">Stil enheden op 1,5 til 2 meter væk i cirka brysthøjde med skærmen vendt mod dig. Træd så ind i billedet med hænderne ude.</string>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s über FEX</string>
|
||||
<string name="app_fex_not_ready">Dieses Programm ist für %1$s-PCs gebaut und läuft über FEX, das noch nicht eingerichtet ist. Öffne Steam einmal, während du angemeldet bist: DroidDeck lässt es FEX und die nötige Steam Linux Runtime herunterladen, danach lässt sich das Programm öffnen.</string>
|
||||
<string name="session_end_program">Dieses Programm</string>
|
||||
<string name="session_end_steam_download">Der Steam-Client konnte nicht heruntergeladen werden. Dein Netzwerk oder VPN blockiert möglicherweise die Server von Steam; prüfe die Verbindung und versuche es erneut.</string>
|
||||
<string name="session_end_steam_setup">Der Steam-Client konnte nicht eingerichtet werden. Prüfe die Verbindung und den freien Speicher und versuche es erneut.</string>
|
||||
<string name="session_end_fex_missing">%1$s ist für x86-PCs gebaut und läuft über FEX, das noch nicht installiert ist. Starte Steam einmal, während du angemeldet bist, und lass es FEX und die Steam Linux Runtime herunterladen. Starte es danach erneut.</string>
|
||||
<string name="add_app_flatpak_hint">Eine Flathub-App-ID oder ihre Flathub-Seite. Flatpak wird zuerst eingerichtet, falls noch nicht geschehen.</string>
|
||||
<string name="add_app_file">Datei</string>
|
||||
@@ -579,16 +577,6 @@
|
||||
<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>
|
||||
@@ -1013,6 +1001,7 @@
|
||||
<string name="games_empty">Spiele, die du in Steam installierst, erscheinen hier nach der Sitzung.</string>
|
||||
<string name="games_play_steam">Mit Steam spielen</string>
|
||||
<string name="games_play_steamvr">Mit SteamVR spielen</string>
|
||||
<string name="games_install_play_steamvr">Herunterladen und mit SteamVR spielen</string>
|
||||
<string name="games_launch_settings">Starteinstellungen</string>
|
||||
<string name="games_launch">Starten</string>
|
||||
<string name="games_files">Spieldateien</string>
|
||||
@@ -1039,9 +1028,6 @@
|
||||
<string name="games_added">Hinzugefügtes Spiel</string>
|
||||
<string name="games_custom">Eigenes Spiel</string>
|
||||
<string name="games_store_storage">Spielspeicher</string>
|
||||
<string name="games_uninstall_body">Löscht seine Dateien und entfernt es aus Steam.</string>
|
||||
<string name="games_remove_title">%1$s entfernen?</string>
|
||||
<string name="games_remove_body">Löscht die Verknüpfung. Die Spieldateien bleiben.</string>
|
||||
<string name="gpu_auto_hint">Das beste Paar für diese GPU, stets aktuell</string>
|
||||
<string name="gpu_manual_hint">Wähle selbst ein Paar</string>
|
||||
<string name="gpu_checking">Suche nach Treibern…</string>
|
||||
@@ -1346,9 +1332,12 @@
|
||||
<string name="session_proton_downloading_mb">Proton Experimental (ARM64) wird heruntergeladen · %1$d von %2$d MB</string>
|
||||
<string name="session_proton_checking">Proton Experimental (ARM64) wird geprüft</string>
|
||||
<string name="session_proton_unpacking">Proton Experimental (ARM64) wird entpackt</string>
|
||||
<string name="session_vr_downloading">VR-Modul wird heruntergeladen</string>
|
||||
<string name="session_vr_downloading_mb">VR-Modul wird heruntergeladen · %1$d von %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">Der Katalog der Laufzeiten war nicht erreichbar. Prüfe die Verbindung und tippe erneut auf Spielen.</string>
|
||||
<string name="session_runtime_install_failed">Die Linux-Laufzeit wurde nicht installiert. Prüfe sie unter Einrichtung und tippe erneut auf Spielen.</string>
|
||||
<string name="session_desktop_install_failed">Der Desktop wurde nicht installiert (%1$s). Prüfe die Verbindung und tippe erneut auf Desktop.</string>
|
||||
<string name="session_vr_install_failed">Das VR-Modul wurde nicht installiert. Prüfe die Verbindung und tippe erneut auf Mit SteamVR spielen.</string>
|
||||
<string name="session_ended_named">%1$s wurde unerwartet beendet. Teile die Protokolle mit einem Fehlerbericht oder versuche es erneut.</string>
|
||||
<string name="session_ended_program">Das Programm wurde unerwartet beendet. Teile die Protokolle mit einem Fehlerbericht oder versuche es erneut.</string>
|
||||
<string name="session_ended_desktop">Der Desktop wurde unerwartet beendet. Teile die Protokolle mit einem Fehlerbericht oder versuche es erneut.</string>
|
||||
@@ -2452,6 +2441,16 @@
|
||||
<string name="hand_camera_placement">Kameraplatzierung</string>
|
||||
<string name="hand_camera_placement_hint">Prüfe, ob die Kamera dich sehen kann, bevor du spielst.</string>
|
||||
<string name="hand_camera_check">Prüfen</string>
|
||||
<string name="vr_module_title">VR-Modul</string>
|
||||
<string name="vr_module_hint">SteamVR auf Brillen, Quest und Cardboard sowie Handtracking per Kamera · %1$s Download</string>
|
||||
<string name="vr_module_installed">Installiert · %1$s</string>
|
||||
<string name="vr_module_downloading">VR-Modul wird heruntergeladen</string>
|
||||
<string name="vr_module_failed">Das VR-Modul konnte nicht heruntergeladen werden. Prüfe die Verbindung und versuche es erneut.</string>
|
||||
<string name="vr_module_removing">VR-Modul wird entfernt</string>
|
||||
<string name="vr_module_busy">Beende die Sitzung, bevor du das VR-Modul entfernst.</string>
|
||||
<string name="vr_module_remove_failed">Das VR-Modul konnte nicht entfernt werden</string>
|
||||
<string name="vr_module_uninstall_title">VR-Modul deinstallieren?</string>
|
||||
<string name="vr_module_uninstall_text">SteamVR und Handtracking per Kamera funktionieren erst wieder, wenn du es erneut herunterlädst. SteamVR selbst, Spiele und Einstellungen bleiben erhalten.</string>
|
||||
<string name="hand_camera_check_title">Kameraplatzierung</string>
|
||||
<string name="hand_camera_how_rear">Stell das Gerät etwa 1,5 bis 2 Meter entfernt in Brusthöhe auf, mit der Rückkamera zu dir gerichtet. Tritt dann mit ausgestreckten Händen ins Bild.</string>
|
||||
<string name="hand_camera_how_front">Stell das Gerät etwa 1,5 bis 2 Meter entfernt in Brusthöhe auf, mit dem Bildschirm zu dir gerichtet. Tritt dann mit ausgestreckten Händen ins Bild.</string>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s μέσω FEX</string>
|
||||
<string name="app_fex_not_ready">Αυτό το πρόγραμμα είναι χτισμένο για υπολογιστές %1$s και εκτελείται μέσω FEX, το οποίο δεν έχει ρυθμιστεί ακόμα. Ανοίξτε το Steam μία φορά ενώ είστε συνδεδεμένοι: το DroidDeck κάνει λήψη του FEX και του Steam Linux Runtime που χρειάζεται, και μετά αυτό ανοίγει.</string>
|
||||
<string name="session_end_program">Αυτό το πρόγραμμα</string>
|
||||
<string name="session_end_steam_download">Δεν ήταν δυνατή η λήψη του πελάτη Steam. Το δίκτυο ή το VPN σας ίσως αποκλείει τους διακομιστές του Steam· ελέγξτε τη σύνδεση και δοκιμάστε ξανά.</string>
|
||||
<string name="session_end_steam_setup">Δεν ήταν δυνατή η ρύθμιση του πελάτη Steam. Ελέγξτε τη σύνδεση και τον ελεύθερο χώρο αποθήκευσης και δοκιμάστε ξανά.</string>
|
||||
<string name="session_end_fex_missing">Το %1$s είναι χτισμένο για υπολογιστές x86 και εκτελείται μέσω FEX, το οποίο δεν έχει εγκατασταθεί ακόμα. Εκκινήστε το Steam μία φορά ενώ είστε συνδεδεμένοι και αφήστε το να κάνει λήψη του FEX και του Steam Linux Runtime, μετά εκκινήστε αυτό ξανά.</string>
|
||||
<string name="add_app_flatpak_hint">Ένα ID εφαρμογής Flathub ή η σελίδα της στο Flathub. Το Flatpak ρυθμίζεται πρώτα, αν δεν έχει ρυθμιστεί ακόμα.</string>
|
||||
<string name="add_app_file">Αρχείο</string>
|
||||
@@ -582,16 +580,6 @@
|
||||
<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>
|
||||
@@ -1024,6 +1012,7 @@
|
||||
<string name="games_empty">Τα παιχνίδια που εγκαθιστάτε στο Steam εμφανίζονται εδώ μετά τη συνεδρία.</string>
|
||||
<string name="games_play_steam">Αναπαραγωγή Steam</string>
|
||||
<string name="games_play_steamvr">Αναπαραγωγή SteamVR</string>
|
||||
<string name="games_install_play_steamvr">Λήψη και αναπαραγωγή SteamVR</string>
|
||||
<string name="games_launch_settings">Ρυθμίσεις εκκίνησης</string>
|
||||
<string name="games_launch">Εκκίνηση</string>
|
||||
<string name="games_files">Αρχεία παιχνιδιού</string>
|
||||
@@ -1050,9 +1039,6 @@
|
||||
<string name="games_added">Προστέθηκε παιχνίδι</string>
|
||||
<string name="games_custom">Προσαρμοσμένο παιχνίδι</string>
|
||||
<string name="games_store_storage">Αποθήκευση παιχνιδιών</string>
|
||||
<string name="games_uninstall_body">Διαγράφει τα αρχεία του και το αφαιρεί από το Steam.</string>
|
||||
<string name="games_remove_title">Αφαίρεση του %1$s;</string>
|
||||
<string name="games_remove_body">Διαγράφει τη συντόμευση. Τα αρχεία του παιχνιδιού παραμένουν.</string>
|
||||
<string name="gpu_auto_hint">Το καλύτερο ζεύγος για αυτή τη GPU, ενημερωμένο</string>
|
||||
<string name="gpu_manual_hint">Επιλέξτε ένα ζεύγος εσείς</string>
|
||||
<string name="gpu_checking">Έλεγχος για προγράμματα οδήγησης…</string>
|
||||
@@ -1357,9 +1343,12 @@
|
||||
<string name="session_proton_downloading_mb">λήψη του Proton Experimental (ARM64) · %1$d από %2$d MB</string>
|
||||
<string name="session_proton_checking">έλεγχος του Proton Experimental (ARM64)</string>
|
||||
<string name="session_proton_unpacking">αποσυμπίεση του Proton Experimental (ARM64)</string>
|
||||
<string name="session_vr_downloading">λήψη της μονάδας VR</string>
|
||||
<string name="session_vr_downloading_mb">λήψη της μονάδας VR · %1$d από %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">Δεν ήταν δυνατή η πρόσβαση στον κατάλογο runtime. Ελέγξτε τη σύνδεση και πατήστε ξανά Αναπαραγωγή.</string>
|
||||
<string name="session_runtime_install_failed">Το Linux runtime δεν εγκαταστάθηκε. Ελέγξτε το runtime στη Ρύθμιση και πατήστε ξανά Αναπαραγωγή.</string>
|
||||
<string name="session_desktop_install_failed">Η επιφάνεια εργασίας δεν εγκαταστάθηκε (%1$s). Ελέγξτε τη σύνδεση και πατήστε ξανά Επιφάνεια εργασίας.</string>
|
||||
<string name="session_vr_install_failed">Η μονάδα VR δεν εγκαταστάθηκε. Ελέγξτε τη σύνδεση και πατήστε ξανά Αναπαραγωγή SteamVR.</string>
|
||||
<string name="session_ended_named">%1$s διακόπηκε αναπάντεχα. Κοινοποιήστε τα αρχεία καταγραφής με μια αναφορά σφάλματος, ή δοκιμάστε ξανά.</string>
|
||||
<string name="session_ended_program">Το πρόγραμμα διακόπηκε αναπάντεχα. Κοινοποιήστε τα αρχεία καταγραφής με μια αναφορά σφάλματος, ή δοκιμάστε ξανά.</string>
|
||||
<string name="session_ended_desktop">Η επιφάνεια εργασίας διακόπηκε αναπάντεχα. Κοινοποιήστε τα αρχεία καταγραφής με μια αναφορά σφάλματος, ή δοκιμάστε ξανά.</string>
|
||||
@@ -2466,6 +2455,16 @@
|
||||
<string name="hand_camera_placement">Τοποθέτηση κάμερας</string>
|
||||
<string name="hand_camera_placement_hint">Βεβαιωθείτε ότι η κάμερα σάς βλέπει πριν παίξετε.</string>
|
||||
<string name="hand_camera_check">Έλεγχος</string>
|
||||
<string name="vr_module_title">Μονάδα VR</string>
|
||||
<string name="vr_module_hint">SteamVR σε γυαλιά, Quest και Cardboard, καθώς και παρακολούθηση χεριών με την κάμερα · λήψη %1$s</string>
|
||||
<string name="vr_module_installed">Εγκατεστημένη · %1$s</string>
|
||||
<string name="vr_module_downloading">Λήψη της μονάδας VR</string>
|
||||
<string name="vr_module_failed">Δεν ήταν δυνατή η λήψη της μονάδας VR. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.</string>
|
||||
<string name="vr_module_removing">Κατάργηση της μονάδας VR</string>
|
||||
<string name="vr_module_busy">Σταματήστε την περίοδο λειτουργίας πριν καταργήσετε τη μονάδα VR.</string>
|
||||
<string name="vr_module_remove_failed">Δεν ήταν δυνατή η κατάργηση της μονάδας VR</string>
|
||||
<string name="vr_module_uninstall_title">Κατάργηση εγκατάστασης της μονάδας VR;</string>
|
||||
<string name="vr_module_uninstall_text">Το SteamVR και η παρακολούθηση χεριών με την κάμερα σταματούν να λειτουργούν μέχρι να τη κατεβάσετε ξανά. Το ίδιο το SteamVR, τα παιχνίδια και οι ρυθμίσεις διατηρούνται.</string>
|
||||
<string name="hand_camera_check_title">Τοποθέτηση κάμερας</string>
|
||||
<string name="hand_camera_how_rear">Στήστε τη συσκευή όρθια σε απόσταση 1,5 έως 2 μέτρων, περίπου στο ύψος του στήθους, με την πίσω κάμερα στραμμένη προς εσάς. Έπειτα μπείτε στο οπτικό πεδίο με τα χέρια τεντωμένα.</string>
|
||||
<string name="hand_camera_how_front">Στήστε τη συσκευή όρθια σε απόσταση 1,5 έως 2 μέτρων, περίπου στο ύψος του στήθους, με την οθόνη στραμμένη προς εσάς. Έπειτα μπείτε στο οπτικό πεδίο με τα χέρια τεντωμένα.</string>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
@@ -35,16 +37,23 @@
|
||||
<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>
|
||||
@@ -59,9 +68,12 @@
|
||||
<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>
|
||||
|
||||
@@ -176,8 +176,6 @@
|
||||
<string name="app_fex_x86">%1$s con FEX</string>
|
||||
<string name="app_fex_not_ready">Este programa está hecho para PC %1$s y se ejecuta con FEX, que aún no está listo. Abre Steam una vez con tu sesión iniciada: DroidDeck hará que descargue FEX y el Steam Linux Runtime que necesita, y después se abrirá.</string>
|
||||
<string name="session_end_program">Este programa</string>
|
||||
<string name="session_end_steam_download">No se ha podido descargar el cliente de Steam. Puede que tu red o VPN esté bloqueando los servidores de Steam; comprueba la conexión y vuelve a intentarlo.</string>
|
||||
<string name="session_end_steam_setup">No se ha podido configurar el cliente de Steam. Comprueba la conexión y el espacio libre y vuelve a intentarlo.</string>
|
||||
<string name="session_end_fex_missing">%1$s está hecho para PC x86 y se ejecuta con FEX, que aún no está instalado. Abre Steam una vez con tu sesión iniciada y deja que descargue FEX y el Steam Linux Runtime; después vuelve a iniciarlo.</string>
|
||||
<string name="add_app_flatpak_hint">Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está.</string>
|
||||
<string name="add_app_file">Archivo</string>
|
||||
@@ -581,16 +579,6 @@
|
||||
<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>
|
||||
@@ -1027,6 +1015,7 @@
|
||||
<string name="games_empty">Los juegos que instales en Steam aparecen aquí después de la sesión.</string>
|
||||
<string name="games_play_steam">Jugar en Steam</string>
|
||||
<string name="games_play_steamvr">Jugar en SteamVR</string>
|
||||
<string name="games_install_play_steamvr">Descargar y jugar en SteamVR</string>
|
||||
<string name="games_launch_settings">Ajustes de inicio</string>
|
||||
<string name="games_launch">Iniciar</string>
|
||||
<string name="games_files">Archivos del juego</string>
|
||||
@@ -1053,9 +1042,6 @@
|
||||
<string name="games_added">Juego añadido</string>
|
||||
<string name="games_custom">Juego personalizado</string>
|
||||
<string name="games_store_storage">Almacenamiento de juegos</string>
|
||||
<string name="games_uninstall_body">Elimina sus archivos y lo quita de Steam.</string>
|
||||
<string name="games_remove_title">¿Quitar %1$s?</string>
|
||||
<string name="games_remove_body">Elimina su acceso directo. Los archivos del juego se conservan.</string>
|
||||
<string name="gpu_auto_hint">El mejor par para esta GPU, siempre actualizado</string>
|
||||
<string name="gpu_manual_hint">Elige un par manualmente</string>
|
||||
<string name="gpu_checking">Buscando controladores…</string>
|
||||
@@ -1367,9 +1353,12 @@
|
||||
<string name="session_proton_downloading_mb">descargando Proton Experimental (ARM64) · %1$d de %2$d MB</string>
|
||||
<string name="session_proton_checking">comprobando Proton Experimental (ARM64)</string>
|
||||
<string name="session_proton_unpacking">descomprimiendo Proton Experimental (ARM64)</string>
|
||||
<string name="session_vr_downloading">descargando el módulo de RV</string>
|
||||
<string name="session_vr_downloading_mb">descargando el módulo de RV · %1$d de %2$d MB</string>
|
||||
<string name="session_runtime_catalog_unreachable">No se pudo acceder al catálogo del runtime. Revisa la conexión y vuelve a pulsar Jugar.</string>
|
||||
<string name="session_runtime_install_failed">El runtime de Linux no se instaló. Revisa el runtime en Configuración y vuelve a pulsar Jugar.</string>
|
||||
<string name="session_desktop_install_failed">El escritorio no se instaló (%1$s). Revisa la conexión y pulsa Escritorio de nuevo.</string>
|
||||
<string name="session_vr_install_failed">El módulo de RV no se instaló. Revisa la conexión y pulsa Jugar en SteamVR de nuevo.</string>
|
||||
<string name="session_ended_named">%1$s se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
|
||||
<string name="session_ended_program">El programa se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
|
||||
<string name="session_ended_desktop">El escritorio se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
|
||||
@@ -2474,6 +2463,16 @@
|
||||
<string name="hand_camera_placement">Posición de la cámara</string>
|
||||
<string name="hand_camera_placement_hint">Comprueba que la cámara pueda verte antes de jugar.</string>
|
||||
<string name="hand_camera_check">Comprobar</string>
|
||||
<string name="vr_module_title">Módulo de RV</string>
|
||||
<string name="vr_module_hint">SteamVR en gafas, Quest y Cardboard, además de seguimiento de manos con la cámara · descarga de %1$s</string>
|
||||
<string name="vr_module_installed">Instalado · %1$s</string>
|
||||
<string name="vr_module_downloading">Descargando el módulo de RV</string>
|
||||
<string name="vr_module_failed">No se ha podido descargar el módulo de RV. Revisa la conexión y vuelve a intentarlo.</string>
|
||||
<string name="vr_module_removing">Quitando el módulo de RV</string>
|
||||
<string name="vr_module_busy">Detén la sesión antes de quitar el módulo de RV.</string>
|
||||
<string name="vr_module_remove_failed">No se ha podido quitar el módulo de RV</string>
|
||||
<string name="vr_module_uninstall_title">¿Desinstalar el módulo de RV?</string>
|
||||
<string name="vr_module_uninstall_text">SteamVR y el seguimiento de manos con la cámara dejarán de funcionar hasta que lo vuelvas a descargar. SteamVR, los juegos y los ajustes se conservan.</string>
|
||||
<string name="hand_camera_check_title">Posición de la cámara</string>
|
||||
<string name="hand_camera_how_rear">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la cámara trasera hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
|
||||
<string name="hand_camera_how_front">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la pantalla hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
|
||||
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user