Compare commits

...
33 Commits
Author SHA1 Message Date
The412Banner 923ffde715 feat: DroidDeck's linux-nightly components, with a Stable/Nightly switch (#1284)
The component catalog also reads the linux-nightly release of
Droid-Deck/DroidDeck-Components (hourly builds of DXVK, VKD3D-Proton and
FEX for glibc Protons). That release holds all three components, so each
file goes to its list by name: DD-dxvk-, DD-vkd3d-proton-, DD-FEXCore-.

A nightly that matches a stable build (same flavor and version, e.g.
DD-dxvk-3.1.1+153-nightly and dxvk-3.1.1-linux) has no row of its own;
if two nightlies match, the newer one is used. On the components page
the stable row gets a Stable | Nightly switch before Get, styled like
the Get button, one focus stop that flips with A or a tap and reports
its state as a switch. In the in-session drawer's download menus the
row gets Stable and Nightly tabs beside it (the existing TabStrip, now
with optional bumpers/compact), each its own focus stop. Get or A on the
row downloads whichever channel is selected. A nightly without a stable
counterpart keeps its own row, listed below the stable ones.

Downloads are allowed from that repository's releases too. A failed
nightly download says the file may have been replaced and asks for a
refresh. Strings in all locales.
2026-10-22 04:23:46 -04:00
Tomasz Kloc 31421d7c02 Let GOG and Epic installs pick a game language (#1199) 2026-10-22 00:38:01 -04:00
Kurt Himebauch f08e7cdcaa Default Deck controls to both trackpads (#1295) 2026-10-22 00:35:46 -04:00
Resul Övür 9f5e258bd6 fix: auto-driver trial rolls back when the display driver crashes at startup (#1031) 2026-10-21 23:56:43 -04:00
Resul Övür a863ca7b31 fix: Steam Controller (Triton) output/feature reports are no longer overwritten unread (#1032) 2026-10-21 23:55:00 -04:00
Resul Övür 6b2ca398e2 fix: a full disk no longer crashes the app during cover art fetches (#1030) 2026-10-21 23:52:35 -04:00
kingokksa 779703a56b Switch the GOG store to GOG's official listing endpoint: fewer requests, the tab now opens in seconds (#786) 2026-10-21 23:51:16 -04:00
The412Banner 9a86fc6ee1 Return to the launcher when a front-end game closes (#395) 2026-10-21 23:20:18 -04:00
Resul Övür 4cd6b46dea fix: in-app update works on Android 9 and 10 (#1066) 2026-10-21 23:18:02 -04:00
Kurt Himebauch bacfa942a0 Revert dedicated second-screen activity (#1288) 2026-10-22 02:53:35 +00:00
Moroni Granja b68bb054a9 fix: Xbox search results open with their metadata and Game Pass installs (#1279) 2026-10-21 22:43:51 -04:00
MaxsTechReview af37432251 perf: FEX's file lookups and opens go through the fast path instead of proot (#1283)
* perf: FEX's in-root path lookups go through the fast path instead of proot

Android has no openat2, so libblsession walks RESOLVE_IN_ROOT lookups for
FEX one component at a time. Every fstatat, readlinkat and openat in that
walk was a raw syscall that stopped in proot. When libblfastpath is loaded,
the walk now calls its fstatat, readlinkat and openat, which answer without
proot and fall back to the raw syscall themselves. Without it the walk uses
the raw syscalls as before.

One lookup drops from 250-940 us to 26-86 us. A Graveyard Keeper launch
makes 37.5k proot stops instead of 54.0k and reaches its first scene
0.31 s sooner.

* test: count fstatat64 where the preload asks for it

* perf: FEX's own file opens go through the fast path instead of proot

FEX opens host files for the game with syscall(SYS_openat), so every one
stopped in proot even when the fast path could answer it. libblsession's
syscall() now sends SYS_openat to the fast path's openat as well, the same
way the in-root walk does, and makes the raw syscall when the fast path is
not loaded.

A Graveyard Keeper launch makes 28.0k proot stops instead of 37.5k; FEX's
openat stops go from 6,739 to none.

* fix: the fast path's start-up reads don't come back through syscall()

libblsession's syscall() now sends SYS_openat to the fast path. A process
named FEX starts the fast path by reading /proc/self/cmdline with
syscall(SYS_openat), which came straight back into the fast path and
waited on its own pthread_once. Its start-up reads now call libc's
syscall directly.
2026-10-21 22:33:39 -04:00
Kurt Himebauch c0703cb809 Default session orientation to landscape (#1287) 2026-10-21 21:27:25 -04:00
kingokksa 829a39a9ca feat: let the built-in controller be turned off (#1207) 2026-10-21 19:49:31 -04:00
MaxsTechReview 8b40bb5c88 fix: Chromium browsers start on the desktop (#1280)
A process's status read for another process is served read-only, so
Chromium's ReadOnlyFile check no longer kills the browser. Chromium-based
AppImages without chrome-sandbox get --no-sandbox, and Flathub Chromium and
Electron apps skip zypak's spawn portal, which cannot work here.
2026-10-21 18:17:07 -04:00
kingokksa a0dbdd6b9a fix: some games never loaded their own Steam touch layout, and the sticks could not be placed (#1058) 2026-10-21 17:39:05 -04:00
Resul Övür aaaa18a7dc fix: manual cloud-save upload/download no longer reports a skipped sync as success (#1068) 2026-10-21 17:32:28 -04:00
Kurt Himebauch da26822714 feat: SteamVR controller types, phone controls on glasses, flat games as VR panels (#1268) 2026-10-21 21:22:21 +00:00
MaxsTechReview c178355d92 perf: performance overlay updates ride the next game frame instead of an extra composite (#1274)
* perf: performance overlay updates ride the next game frame instead of an extra composite

* gamescope: a held overlay repaint is only cleared once an output was painted

* gamescope: release.env points at p12
2026-10-21 17:20:12 -04:00
MaxsTechReview 1be8495ee5 perf: network tables Android refuses are served from the synthesized copy without retrying the open (#1275) 2026-10-21 17:18:11 -04:00
MaxsTechReview 81500fe184 perf: SysV message queues stay mapped instead of reopening per call (#1272) 2026-10-21 17:14:03 -04:00
MaxsTechReview cdc0866cfe perf: games skip Steam's install-script evaluator once it has finished (#1273) 2026-10-21 16:59:52 -04:00
greg 6a3583d744 Add support for Java 17 via mise (#1231) 2026-10-21 18:30:34 +00:00
kingokksa 23bca1b974 fix: give the touch gesture dialog room to scroll on phones (#1214) 2026-10-21 14:14:03 -04:00
Kurt Himebauch d51f8ed4ff feat: Rokid Max and Air glasses with 3DoF, SBS modes, 120 Hz and brightness (#1266) 2026-10-21 14:11:01 -04:00
Kurt Himebauch fba6a5d0f2 feat: Remote Play host: PipeWire capture, hardware encoding, touch and game audio (#1139)
* feat: run SteamOS PipeWire and WirePlumber for gamescope screen capture

* feat: hardware Remote Play encoding through MediaCodec behind Steam's V4L2 stage

* feat: deliver Remote Play touches to gamescope as touches

* feat: route game audio through PulseAudio while a Remote Play client is connected

* fix: keep the Remote Play marker when a forked child closes the encoder

* fix: allow standalone preload tests without V4L2 encoder hook
2026-10-21 17:57:24 +00:00
Kurt Himebauch 4992cf76dd feat: local builds reuse the add-on packs a CI build installed (#1265) 2026-10-21 17:23:16 +00:00
FX1922 c2c80c3540 fix: keep controller focus after installing a boot movie; Xbox and itch artwork covered (#1229) 2026-10-21 13:13:31 -04:00
kingokksa 7d691282fa fix: store dots stay dim when signed out and stores open on the first arranged tab (#1236) 2026-10-21 13:12:49 -04:00
0x1Nawaf dcb269d5ea feat: the performance HUD adds a colored frame-time graph, battery temperature, and opacity controls (#1256) 2026-10-21 13:12:23 -04:00
Kurt Himebauch f67398e6bc feat: Decky Loader update channel selector (#1264) 2026-10-21 17:08:46 +00:00
Kurt Himebauch 003064769d feat: warn before installing a Decky plugin ZIP (#1263) 2026-10-21 12:47:10 -04:00
Kurt Himebauch 9fe8bf6955 fix: keep Steam downloads running across suspend with Manual policy (#1262) 2026-10-21 15:45:39 +00:00
204 changed files with 6561 additions and 1170 deletions

No files matched your search

+13
View File
@@ -321,6 +321,17 @@ jobs:
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
mkdir -p pw-pkgs
grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
rm pw-pkgs/pkg.tar.zst
done
tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
rm -rf pw-pkgs
ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
T="$D/usr/local/lib/droiddeck-msitools"
mkdir -p "$T" msi-pkgs
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
@@ -478,6 +489,8 @@ jobs:
/tmp/test-xr-tracking
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
/tmp/test-xr-touch
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
/tmp/test-pad-controller-config
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
/tmp/test-xr-vsync
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
+3 -3
View File
@@ -21,7 +21,7 @@ On Android 14 and newer, before Steam launches, you must turn on **Disable child
## Smart glasses
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, and XREAL connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, XREAL, and Rokid connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
## Community
@@ -49,10 +49,10 @@ Compatibility and performance vary by device; hardware validation is limited. De
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment.
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment. Chromium and Electron apps from Flathub start their browser processes through [zypak](https://github.com/refi64/zypak) by [@refi64](https://github.com/refi64) (BSD-3-Clause); its source was read to see that it always spawns them through Flatpak's spawn portal, which is why DroidDeck starts them directly without Chromium's sandbox instead.
-9
View File
@@ -177,15 +177,6 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.SecondScreenActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/SessionTheme"
android:screenOrientation="sensorLandscape"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".gpu.SteamVrRestartActivity"
android:exported="false"
+4
View File
@@ -39,6 +39,10 @@ target_link_libraries(deviceinfo dl)
add_library(termux SHARED termux/termux.c)
add_library(ddvenc SHARED venc/venc_server.cpp)
target_compile_features(ddvenc PRIVATE cxx_std_17)
target_link_libraries(ddvenc mediandk log)
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
if(DROIDDECK_VITURE_SDK_DIR)
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
+18 -3
View File
@@ -1184,8 +1184,9 @@ struct TritonSharedData {
uint32_t input_length;
uint8_t input[64];
uint64_t output_seq;
uint32_t output_feature;
uint32_t output_length;
uint16_t output_feature;
uint16_t output_length;
uint32_t output_ack;
uint8_t output[64];
uint64_t get_request;
uint64_t get_reply;
@@ -1202,6 +1203,8 @@ static_assert(offsetof(TritonSharedData, report_id) == 16, "Triton report ID off
static_assert(offsetof(TritonSharedData, input) == 24, "Triton input offset");
static_assert(offsetof(TritonSharedData, output_seq) == 88, "Triton output sequence offset");
static_assert(offsetof(TritonSharedData, output_feature) == 96, "Triton output feature offset");
static_assert(offsetof(TritonSharedData, output_length) == 98, "Triton output length offset");
static_assert(offsetof(TritonSharedData, output_ack) == 100, "Triton output ack offset");
static_assert(offsetof(TritonSharedData, output) == 104, "Triton output offset");
static_assert(offsetof(TritonSharedData, get_request) == 168, "Triton feature request offset");
static_assert(offsetof(TritonSharedData, get_reply) == 176, "Triton feature reply offset");
@@ -1219,6 +1222,8 @@ static constexpr int TRITON_REPORT_ID_BYTES = 1;
static constexpr int TRITON_STATE_BYTES = 45;
static constexpr int TRITON_REPORT_INTERVAL_US = 4000;
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500;
// The app clears output_length once it has taken a report; a writer waits this long for that before overwriting.
static constexpr int TRITON_OUTPUT_TIMEOUT_MS = 50;
static constexpr const char *TRITON_NAME = "Steam Controller";
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
static const uint8_t kTritonReportDescriptor[] = {
@@ -2574,11 +2579,21 @@ __attribute__((visibility("hidden"))) static void triton_queue_output(DeckHidraw
if (!data || !size || size > 64 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return;
auto *shared = reinterpret_cast<TritonSharedData *>(reinterpret_cast<uint8_t *>(triton.ring) + TRITON_SHARED_OFFSET);
if (__atomic_load_n(&shared->magic, __ATOMIC_ACQUIRE) != TRITON_SHARED_MAGIC) return;
static std::mutex one_at_a_time;
std::lock_guard<std::mutex> guard(one_at_a_time);
for (int waited = 0;
static_cast<uint32_t>(__atomic_load_n(&shared->output_seq, __ATOMIC_ACQUIRE)) !=
__atomic_load_n(&shared->output_ack, __ATOMIC_ACQUIRE) &&
__atomic_load_n(&shared->connected, __ATOMIC_ACQUIRE) != 0 && waited < TRITON_OUTPUT_TIMEOUT_MS * 4;
waited++) {
struct timespec interval = {0, 250 * 1000L};
nanosleep(&interval, nullptr);
}
uint64_t seq = __atomic_load_n(&shared->output_seq, __ATOMIC_RELAXED);
if (seq & 1) seq++;
__atomic_store_n(&shared->output_seq, seq + 1, __ATOMIC_RELEASE);
shared->output_feature = feature ? 1 : 0;
shared->output_length = static_cast<uint32_t>(size);
shared->output_length = static_cast<uint16_t>(size);
memcpy(shared->output, data, size);
if (size < sizeof(shared->output)) memset(shared->output + size, 0, sizeof(shared->output) - size);
__atomic_thread_fence(__ATOMIC_RELEASE);
+55
View File
@@ -0,0 +1,55 @@
#ifndef DROIDDECK_VENC_PROTO_H
#define DROIDDECK_VENC_PROTO_H
#include <stdint.h>
/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and
* MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS;
* every frame gets one reply, touches get none. */
#define VENC_MAX_BUFFERS 16
enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5 };
enum venc_touch_action { VENC_TOUCH_DOWN = 0, VENC_TOUCH_MOVE = 1, VENC_TOUCH_UP = 2 };
enum venc_codec { VENC_H264 = 0, VENC_HEVC = 1 };
struct venc_config {
uint32_t type;
uint32_t codec;
uint32_t width, height, stride, rows;
uint32_t fps_num, fps_den;
uint32_t bitrate, peak_bitrate, vbr;
uint32_t gop;
int32_t profile, level;
uint32_t inputs, outputs;
uint32_t input_size, output_size;
};
struct venc_frame {
uint32_t type;
uint32_t input, output;
uint32_t bytes;
uint32_t keyframe;
int64_t pts_us;
};
struct venc_done {
uint32_t type;
int32_t status;
uint32_t bytes;
uint32_t keyframe;
};
struct venc_bitrate {
uint32_t type;
uint32_t bitrate;
};
/* Remote Play's touch-as-mouse turned back into a touch; x and y are fractions of the screen in 1/65535. */
struct venc_touch {
uint32_t type;
uint32_t action;
uint32_t x, y;
};
#endif
+346
View File
@@ -0,0 +1,346 @@
#include <android/log.h>
#include <dlfcn.h>
#include <jni.h>
#include <media/NdkMediaCodec.h>
#include <media/NdkMediaFormat.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
#include "venc_proto.h"
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckVenc", __VA_ARGS__)
namespace {
struct Mapping {
uint8_t *data = nullptr;
size_t size = 0;
};
constexpr int32_t kColorFormatYuv420SemiPlanar = 21;
constexpr uint32_t kBufferFlagKeyFrame = 1;
constexpr uint32_t kBufferFlagCodecConfig = 2;
constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt
constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32)
int32_t avc_profile(int32_t v4l2) {
switch (v4l2) {
case 0: return 1;
case 1: return 0x10000;
case 2: return 2;
case 3: return 4;
case 4: return 8;
default: return 0;
}
}
class Session {
public:
explicit Session(int fd) : fd_(fd) {}
~Session() {
release();
close(fd_);
}
void run() {
for (;;) {
uint8_t buf[256];
int fds[VENC_MAX_BUFFERS * 2];
int nfds = 0;
ssize_t n = receive(buf, sizeof buf, fds, &nfds);
if (n <= 0) return;
uint32_t type;
memcpy(&type, buf, sizeof type);
if (type == VENC_CONFIG && n == (ssize_t)sizeof(venc_config)) {
venc_config c;
memcpy(&c, buf, sizeof c);
bool ok = configure(c, fds, nfds);
for (int i = 0; i < nfds; i++) close(fds[i]);
reply(ok ? 0 : -1, 0, false);
} else if (type == VENC_FRAME && n == (ssize_t)sizeof(venc_frame)) {
venc_frame f;
memcpy(&f, buf, sizeof f);
uint32_t bytes = 0;
bool key = false;
bool ok = encode(f, &bytes, &key);
reply(ok ? 0 : -1, bytes, key);
} else if (type == VENC_TOUCH && n == (ssize_t)sizeof(venc_touch)) {
venc_touch t;
memcpy(&t, buf, sizeof t);
touch(t);
for (int i = 0; i < nfds; i++) close(fds[i]);
} else if (type == VENC_BITRATE && n == (ssize_t)sizeof(venc_bitrate)) {
venc_bitrate b;
memcpy(&b, buf, sizeof b);
set_bitrate(b.bitrate);
for (int i = 0; i < nfds; i++) close(fds[i]);
} else {
LOG("unexpected message %u of %zd bytes; closing", type, n);
for (int i = 0; i < nfds; i++) close(fds[i]);
return;
}
}
}
private:
ssize_t receive(uint8_t *buf, size_t len, int *fds, int *nfds) {
char control[CMSG_SPACE(sizeof(int) * VENC_MAX_BUFFERS * 2)];
iovec iov = {buf, len};
msghdr m = {};
m.msg_iov = &iov;
m.msg_iovlen = 1;
m.msg_control = control;
m.msg_controllen = sizeof control;
ssize_t n = recvmsg(fd_, &m, MSG_CMSG_CLOEXEC);
for (cmsghdr *c = CMSG_FIRSTHDR(&m); n > 0 && c; c = CMSG_NXTHDR(&m, c)) {
if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) continue;
int count = (int)((c->cmsg_len - CMSG_LEN(0)) / sizeof(int));
memcpy(fds, CMSG_DATA(c), sizeof(int) * std::min(count, VENC_MAX_BUFFERS * 2));
*nfds = std::min(count, VENC_MAX_BUFFERS * 2);
}
return n;
}
void reply(int32_t status, uint32_t bytes, bool key) {
venc_done d = {VENC_DONE, status, bytes, key ? 1u : 0u};
send(fd_, &d, sizeof d, MSG_NOSIGNAL);
}
void release() {
if (codec_) {
AMediaCodec_stop(codec_);
AMediaCodec_delete(codec_);
codec_ = nullptr;
}
for (auto &m : inputs_) munmap(m.data, m.size);
for (auto &m : outputs_) munmap(m.data, m.size);
inputs_.clear();
outputs_.clear();
header_.clear();
}
AMediaCodec *create(const venc_config &c, bool with_profile) {
const char *mime = c.codec == VENC_HEVC ? "video/hevc" : "video/avc";
AMediaCodec *codec = AMediaCodec_createEncoderByType(mime);
if (!codec) return nullptr;
float fps = c.fps_num ? (float)c.fps_den / (float)c.fps_num : 60.0f;
AMediaFormat *f = AMediaFormat_new();
AMediaFormat_setString(f, AMEDIAFORMAT_KEY_MIME, mime);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_WIDTH, (int32_t)c.width);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_HEIGHT, (int32_t)c.height);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_COLOR_FORMAT, kColorFormatYuv420SemiPlanar);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_BIT_RATE, (int32_t)std::max(c.bitrate, 500000u));
AMediaFormat_setInt32(f, "bitrate-mode", c.vbr ? 1 : 2);
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_FRAME_RATE, fps);
AMediaFormat_setFloat(f, "operating-rate", fps);
AMediaFormat_setInt32(f, "priority", 0);
AMediaFormat_setInt32(f, "latency", 1);
AMediaFormat_setInt32(f, "max-bframes", 0);
AMediaFormat_setInt32(f, "prepend-sps-pps-to-idr-frames", 1);
float gop_seconds = c.gop && c.gop < 0xffff ? (float)c.gop / fps : 3600.0f;
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, std::max(gop_seconds, 0.1f));
if (with_profile) {
int32_t profile = c.codec == VENC_HEVC ? (c.profile == 2 ? 2 : 1) : avc_profile(c.profile);
if (profile) AMediaFormat_setInt32(f, "profile", profile);
}
media_status_t s = AMediaCodec_configure(codec, f, nullptr, nullptr, AMEDIACODEC_CONFIGURE_FLAG_ENCODE);
AMediaFormat_delete(f);
if (s == AMEDIA_OK) s = AMediaCodec_start(codec);
if (s != AMEDIA_OK) {
AMediaCodec_delete(codec);
return nullptr;
}
return codec;
}
bool configure(const venc_config &c, const int *fds, int nfds) {
release();
if (c.inputs + c.outputs != (uint32_t)nfds || c.width < 64 || c.height < 64 || c.stride < c.width ||
c.rows < c.height || (uint64_t)c.stride * c.rows * 3 / 2 > c.input_size) {
LOG("bad config: %u+%u buffers for %d fds, %ux%u stride %u", c.inputs, c.outputs, nfds, c.width, c.height,
c.stride);
return false;
}
for (int i = 0; i < nfds; i++) {
bool input = (uint32_t)i < c.inputs;
size_t size = input ? c.input_size : c.output_size;
void *p = mmap(nullptr, size, input ? PROT_READ : PROT_READ | PROT_WRITE, MAP_SHARED, fds[i], 0);
if (p == MAP_FAILED) {
LOG("could not map buffer %d: %s", i, strerror(errno));
release();
return false;
}
(input ? inputs_ : outputs_).push_back({(uint8_t *)p, size});
}
codec_ = create(c, true);
if (!codec_) codec_ = create(c, false);
if (!codec_) {
LOG("no %s encoder for %ux%u", c.codec == VENC_HEVC ? "HEVC" : "H.264", c.width, c.height);
release();
return false;
}
config_ = c;
dst_stride_ = (int32_t)c.width;
dst_rows_ = (int32_t)c.height;
using InputFormat = AMediaFormat *(*)(AMediaCodec *);
static auto input_format = (InputFormat)dlsym(RTLD_DEFAULT, "AMediaCodec_getInputFormat");
if (AMediaFormat *in = input_format ? input_format(codec_) : nullptr) {
int32_t v;
if (AMediaFormat_getInt32(in, "stride", &v) && v >= (int32_t)c.width) dst_stride_ = v;
if (AMediaFormat_getInt32(in, "slice-height", &v) && v >= (int32_t)c.height) dst_rows_ = v;
AMediaFormat_delete(in);
}
LOG("%s %ux%u at %.2f fps, %u kbit/s %s, input stride %d rows %d", c.codec == VENC_HEVC ? "HEVC" : "H.264",
c.width, c.height, c.fps_num ? (double)c.fps_den / c.fps_num : 0.0, c.bitrate / 1000,
c.vbr ? "VBR" : "CBR", dst_stride_, dst_rows_);
return true;
}
static void touch(const venc_touch &t) {
using SendTouch = void (*)(int, int, int, int);
static SendTouch send_touch = [] {
void *wayland = dlopen("libdroiddeckwayland.so", RTLD_NOW | RTLD_NOLOAD);
auto fn = wayland ? (SendTouch)dlsym(wayland, "droiddeck_wayland_send_touch") : nullptr;
if (!fn) LOG("remote touch unavailable: %s", dlerror());
return fn;
}();
if (!send_touch || t.action > VENC_TOUCH_UP) return;
int x = (int)((uint64_t)std::min(t.x, 65535u) * (kTouchWidth - 1) / 65535);
int y = (int)((uint64_t)std::min(t.y, 65535u) * (kTouchHeight - 1) / 65535);
send_touch((int)t.action, kRemoteTouchId, x, y);
}
void set_bitrate(uint32_t bitrate) {
if (!codec_) return;
AMediaFormat *p = AMediaFormat_new();
AMediaFormat_setInt32(p, "video-bitrate", (int32_t)std::max(bitrate, 500000u));
AMediaCodec_setParameters(codec_, p);
AMediaFormat_delete(p);
}
bool encode(const venc_frame &f, uint32_t *bytes, bool *key) {
if (!codec_ || f.input >= inputs_.size() || f.output >= outputs_.size()) return false;
if (f.keyframe) {
AMediaFormat *p = AMediaFormat_new();
AMediaFormat_setInt32(p, "request-sync", 0);
AMediaCodec_setParameters(codec_, p);
AMediaFormat_delete(p);
}
ssize_t in = AMediaCodec_dequeueInputBuffer(codec_, 100000);
if (in < 0) return false;
size_t capacity = 0;
uint8_t *dst = AMediaCodec_getInputBuffer(codec_, (size_t)in, &capacity);
size_t need = (size_t)dst_stride_ * dst_rows_ + (size_t)dst_stride_ * ((config_.height + 1) / 2);
if (!dst || capacity < need) return false;
const uint8_t *src = inputs_[f.input].data;
for (uint32_t y = 0; y < config_.height; y++)
memcpy(dst + (size_t)y * dst_stride_, src + (size_t)y * config_.stride, config_.width);
const uint8_t *src_uv = src + (size_t)config_.stride * config_.rows;
uint8_t *dst_uv = dst + (size_t)dst_stride_ * dst_rows_;
for (uint32_t y = 0; y < (config_.height + 1) / 2; y++)
memcpy(dst_uv + (size_t)y * dst_stride_, src_uv + (size_t)y * config_.stride, config_.width);
if (AMediaCodec_queueInputBuffer(codec_, (size_t)in, 0, need, (uint64_t)std::max<int64_t>(f.pts_us, 0), 0) !=
AMEDIA_OK)
return false;
Mapping &out = outputs_[f.output];
for (int tries = 0; tries < 20; tries++) {
AMediaCodecBufferInfo info;
ssize_t o = AMediaCodec_dequeueOutputBuffer(codec_, &info, 20000);
if (o == AMEDIACODEC_INFO_TRY_AGAIN_LATER || o == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED ||
o == AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED)
continue;
if (o < 0) return false;
size_t size = 0;
uint8_t *data = AMediaCodec_getOutputBuffer(codec_, (size_t)o, &size);
const uint8_t *payload = data ? data + info.offset : nullptr;
size_t length = data ? (size_t)info.size : 0;
if (info.flags & kBufferFlagCodecConfig) {
header_.assign(payload, payload + length);
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
continue;
}
*key = (info.flags & kBufferFlagKeyFrame) != 0;
bool prepend = *key && !header_.empty() &&
(length < header_.size() || memcmp(payload, header_.data(), header_.size()) != 0);
size_t total = length + (prepend ? header_.size() : 0);
bool fits = payload && total <= out.size;
if (fits) {
if (prepend) memcpy(out.data, header_.data(), header_.size());
memcpy(out.data + (prepend ? header_.size() : 0), payload, length);
*bytes = (uint32_t)total;
}
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
return fits;
}
return false;
}
int fd_;
AMediaCodec *codec_ = nullptr;
venc_config config_ = {};
int32_t dst_stride_ = 0, dst_rows_ = 0;
std::vector<Mapping> inputs_, outputs_;
std::vector<uint8_t> header_;
};
std::atomic<int> g_listener{-1};
void serve(int listener) {
for (;;) {
int fd = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC);
if (fd < 0) {
if (errno == EINTR) continue;
return;
}
std::thread([fd] {
Session s(fd);
s.run();
}).detach();
}
}
} // namespace
extern "C" JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) {
if (g_listener.load() >= 0) return JNI_TRUE;
const char *path = env->GetStringUTFChars(jpath, nullptr);
sockaddr_un addr = {};
addr.sun_family = AF_UNIX;
bool fits = strlen(path) < sizeof addr.sun_path;
if (fits) strcpy(addr.sun_path, path);
int fd = fits ? socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0) : -1;
if (fd >= 0) {
unlink(path);
if (bind(fd, (sockaddr *)&addr, sizeof addr) != 0 || listen(fd, 4) != 0) {
LOG("could not listen on %s: %s", path, strerror(errno));
close(fd);
fd = -1;
}
}
env->ReleaseStringUTFChars(jpath, path);
if (fd < 0) return JNI_FALSE;
g_listener.store(fd);
std::thread(serve, fd).detach();
return JNI_TRUE;
}
extern "C" JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStop(JNIEnv *, jobject) {
int fd = g_listener.exchange(-1);
if (fd >= 0) {
shutdown(fd, SHUT_RDWR);
close(fd);
}
}
@@ -11,29 +11,46 @@ import java.io.File
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var deckyInstalled by mutableStateOf<String?>(null)
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
var deckyLatest by mutableStateOf<DeckyManager.Release?>(null)
var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE)
private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList())
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
var deckyDownloadingBinary by mutableStateOf(false)
var deckySupervisor by mutableStateOf(false)
var deckyPendingZip by mutableStateOf<File?>(null)
var deckyReleaseRequest = 0
fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyBranch = DeckyManager.branch(activity)
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChannels = channels
deckyLatest = DeckyManager.latest(channels, deckyBranch)
deckyChecking = false
}
}
}, "decky-releases").start()
}
fun switchDeckyBranch(branch: Int) {
if (branch == deckyBranch || deckyStage != null || SessionState.running) return
val saved = runCatching { DeckyManager.setBranch(activity, branch) }
if (saved.isFailure) {
android.widget.Toast.makeText(activity, R.string.decky_channel_failed, android.widget.Toast.LENGTH_LONG).show()
return
}
deckyBranch = branch
deckyLatest = DeckyManager.latest(deckyChannels, branch)
refreshDecky()
}
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
@@ -49,6 +66,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
}
fun importPluginZip(archive: File) {
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyPendingZip = archive
}
fun cancelPluginZip() {
deckyPendingZip = null
}
fun confirmPluginZip() {
val archive = deckyPendingZip ?: return
deckyPendingZip = null
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
Thread({
@@ -119,6 +119,7 @@ class MainActivity : ComponentActivity() {
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var returnAfterGame by mutableStateOf(true)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
@@ -160,7 +161,7 @@ class MainActivity : ComponentActivity() {
private var directAudio by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
private var downloadMirror by mutableStateOf("")
@@ -768,6 +769,9 @@ class MainActivity : ComponentActivity() {
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
)
val page: (@Composable () -> Unit)? = when {
showPerformance -> { { PerformanceHost() } }
showComponents -> { { ComponentsHost() } }
@@ -1631,6 +1635,7 @@ class MainActivity : ComponentActivity() {
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1646,7 +1651,8 @@ class MainActivity : ComponentActivity() {
wifiDiscoveryAsked = wifiDiscoveryAsked,
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null,
deckyBranch = decky.deckyBranch,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyDownloadingBinary = decky.deckyDownloadingBinary,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
@@ -1716,6 +1722,7 @@ class MainActivity : ComponentActivity() {
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
@@ -1729,6 +1736,7 @@ class MainActivity : ComponentActivity() {
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyBranch = { branch -> decky.switchDeckyBranch(branch) },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
@@ -1881,6 +1889,7 @@ class MainActivity : ComponentActivity() {
steamClientArch = SessionPrefs.steamClientArch(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
returnAfterGame = SessionPrefs.returnAfterGame(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
loadBootMovie()
@@ -29,6 +29,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -111,7 +112,7 @@ import com.droiddeck.launcher.ui.HudText
import com.droiddeck.launcher.ui.LoadingOverlay
import com.droiddeck.launcher.ui.SessionDrawer
import com.droiddeck.launcher.ui.SessionPausedOverlay
import com.droiddeck.launcher.ui.SecondScreenWindow
import com.droiddeck.launcher.ui.SecondScreenPresentation
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
@@ -306,6 +307,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
private var achievementsOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var hudFps by mutableStateOf(true)
private var hudBattery by mutableStateOf(true)
private var hudGraph by mutableStateOf(true)
private var hudColor by mutableStateOf(SessionPrefs.HUD_COLOR_MINT)
private var hudOpacity by mutableStateOf(SessionPrefs.HUD_OPACITY_DEFAULT)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var effects by mutableStateOf(ScreenEffects.OFF)
@@ -361,7 +367,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
private var started = false
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenWindow? = null
private var secondScreenPresentation: SecondScreenPresentation? = null
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
@@ -469,6 +475,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this))
padBridge = bridge
SessionState.padBridge = bridge
if (Build.VERSION.SDK_INT >= 31) {
@@ -638,8 +645,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model)
if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, hud.graph, hudColor, hudOpacity)
val phonePanel = glassesOutputActive && !loading.visible && !drawerOpen && !pcKeyboardOpen
if (phonePanel) GlassesPhonePanel(glasses, steamVrStatus == SteamVr.Status.RUNNING, headTracking)
if (trackingRecovery != null && !drawerOpen && !pcKeyboardOpen && !loading.visible) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { com.droiddeck.launcher.ui.TrackingRecoveryView(it) },
@@ -647,14 +655,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
update = { it.update(trackingRecovery, !glassesOutputActive && glasses.usable && glasses.sbs) },
)
}
if (GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) &&
if (!phonePanel && GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) &&
headTracking && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesRecenterButton(
controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100,
onRecenterJoyCons = if (steamVrStatus == SteamVr.Status.RUNNING &&
glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
joyConVr?.let { { it.recenter() } } else null,
) { headTracker?.recenter() }
if (glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot,
if (!phonePanel && glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot,
controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100) {
drawerPage = com.droiddeck.launcher.ui.SessionDrawerPage.GLASSES
drawerOpen = true
@@ -752,6 +760,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
hudFps = hudFps,
hudBattery = hudBattery,
hudGraph = hudGraph,
hudColor = hudColor,
hudOpacity = hudOpacity,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
effects = effects,
cardboard = cardboard,
@@ -779,6 +792,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onHudFps = { on -> SessionPrefs.setHudShowFps(this@SessionActivity, on); hudFps = on; hud.refresh() },
onHudBattery = { on -> SessionPrefs.setHudShowBattery(this@SessionActivity, on); hudBattery = on; hud.refresh() },
onHudGraph = { on -> SessionPrefs.setHudShowGraph(this@SessionActivity, on); hudGraph = on; hud.refresh() },
onHudColor = { color -> SessionPrefs.setHudColor(this@SessionActivity, color); hudColor = color },
onHudOpacity = { percent -> SessionPrefs.setHudOpacity(this@SessionActivity, percent); hudOpacity = percent },
onUpscaler = { m ->
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
WaylandCompositor.nativeSetUpscaler(m)
@@ -998,6 +1016,54 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, 90)
}
@androidx.compose.runtime.Composable
private fun GlassesPhonePanel(glasses: com.droiddeck.launcher.glasses.GlassesState, vr: Boolean, headTracking: Boolean) {
val battery by androidx.compose.runtime.produceState<String?>(null) {
while (true) {
val b = registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED))
val level = b?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = b?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, 100) ?: 100
val tenths = b?.getIntExtra(android.os.BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1
value = if (level < 0) null else getString(R.string.glasses_phone_battery, level * 100 / scale.coerceAtLeast(1), tenths / 10)
kotlinx.coroutines.delay(30_000)
}
}
val pads = com.droiddeck.launcher.input.VrControllers.settings.collectAsState().value
com.droiddeck.launcher.ui.GlassesPhonePanel(glasses.model, battery, com.droiddeck.launcher.ui.GlassesPhoneActions(
onRecenter = if (headTracking) ({ headTracker?.recenter() }) else null,
onRecenterJoyCons = if (vr && glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
joyConVr?.let { { it.recenter() } } else null,
onSteamVrMenu = if (vr && SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onSwitchSticks = if (vr && pads.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ({ switchVrSticks() }) else null,
controllers = getString(when (pads.emulate) {
com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_off
com.droiddeck.launcher.input.VrControllers.Emulate.INDEX -> R.string.vr_controllers_index
com.droiddeck.launcher.input.VrControllers.Emulate.TOUCH -> R.string.vr_controllers_touch
com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_vive
}),
onNextControllers = if (vr) ({
val all = com.droiddeck.launcher.input.VrControllers.Emulate.entries
com.droiddeck.launcher.input.VrControllers.setEmulate(this, all[(pads.emulate.ordinal + 1) % all.size])
}) else null,
onKeyboard = { keyboard?.toggle() },
onSessionMenu = { drawerOpen = true },
onStopSteamVr = if (vr) ({ steamVrAction("stop") }) else null,
brightness = glasses.brightness,
brightnessMax = glasses.brightnessMax,
onBrightness = if (glasses.canControl && glasses.hardwareControls) Glasses::setBrightness else null,
))
}
/** The SteamVR driver switches pad sticks between hands and game on both stick clicks together. */
private fun switchVrSticks() {
val chord = { down: Boolean -> padBridge?.applyTouch { state ->
state.press(com.droiddeck.launcher.input.PadState.L3, down)
state.press(com.droiddeck.launcher.input.PadState.R3, down)
} }
chord(true)
uiHandler.postDelayed({ chord(false) }, 120)
}
private fun triggerSteamQam() {
drawerOpen = false
padBridge?.triggerQam()
@@ -1343,6 +1409,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
unfoldedControls = SessionPrefs.unfoldedControls(this)
picturePosition = SessionPrefs.picturePosition(this)
hudOn = SessionPrefs.hudEnabled(this)
hudFps = SessionPrefs.hudShowFps(this)
hudBattery = SessionPrefs.hudShowBattery(this)
hudGraph = SessionPrefs.hudShowGraph(this)
hudColor = SessionPrefs.hudColor(this)
hudOpacity = SessionPrefs.hudOpacity(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
effects = SessionPrefs.screenEffects(this)
@@ -1525,10 +1596,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
com.droiddeck.launcher.input.VrControllers.publish(this)
val turnip = TurnipDriver(this)
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "surface.driver-setup")
if (!CompositorHost.isStarted) CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
if (!CompositorHost.isStarted) {
CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
com.droiddeck.launcher.gpu.AutoDriverTrial.displayStarting(this, SessionPrefs.androidDriver(this))
}
val driverId = if (CompositorHost.isStarted) null else turnip.install()
val previous = SessionState.outputSize
@@ -2336,7 +2411,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
var presentation = secondScreenPresentation
if (presentation?.display?.displayId != target.displayId) {
presentation?.closeControls()
presentation = SecondScreenWindow(
presentation = SecondScreenPresentation(
this, target,
sendPointer = ::moveSecondScreenPointer,
sendButton = { button, pressed ->
@@ -2676,7 +2751,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi || unfoldedPanel != null || pcKeyboardOpen) {
if (pipUi || unfoldedPanel != null || pcKeyboardOpen ||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
@@ -19,7 +19,10 @@ import java.io.FileInputStream
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
* was made with install, whichever host serves them.
*/
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
internal class AddonPack(
val name: String, private val sha256: String, val size: Long, private val required: List<String>,
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
) {
sealed interface State {
data object Missing : State
data class Downloading(val progress: Float) : State
@@ -40,10 +43,20 @@ internal class AddonPack(val name: String, private val sha256: String, val size:
private fun root(context: Context) = File(context.filesDir, "addons/$name")
/** App-private and written only after the archive matched the pinned digest. */
fun dir(context: Context) = File(root(context), sha256)
fun dir(context: Context) = File(root(context), version(context))
fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
// A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed.
private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
.filter { dir ->
dir.isDirectory && runCatching { File(dir, COMPLETE).readText().trim() }.getOrNull() == dir.name &&
required.all { File(dir, it).isFile }
}
.maxByOrNull { it.lastModified() }?.name
fun refresh(context: Context): State {
val now = mutableState.value
@@ -86,7 +99,7 @@ internal class AddonPack(val name: String, private val sha256: String, val size:
private fun install(context: Context): Boolean {
if (!available) {
Log.w(TAG, "$name: this build has no pack")
Log.w(TAG, if (reuseDeviceCopy) "$name: this build has no pack and none is installed" else "$name: this build has no pack")
return false
}
val root = root(context).apply { mkdirs() }
@@ -252,6 +252,7 @@ object DeviceReport {
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Return to launcher after a game", SessionPrefs.returnAfterGame(context))
k("Microphone", SessionPrefs.micEnabled(context))
k("On-screen controls", SessionPrefs.oscMode(context))
k("Touch mode", SessionPrefs.touchMode(context))
@@ -323,8 +323,8 @@ object AddedGameArt {
if (dst.isFile) continue
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
val tmp = File(cache, "$localName.tmp")
tmp.writeBytes(bytes)
if (tmp.renameTo(dst)) { got = true; File(cache, "$localName.src").writeText(STEAM) }
if (runCatching { tmp.writeBytes(bytes) }.isFailure) { tmp.delete(); continue }
if (tmp.renameTo(dst)) { got = true; runCatching { File(cache, "$localName.src").writeText(STEAM) } }
}
return got
}
@@ -63,7 +63,8 @@ object CoverArt {
missFile(context, rom).delete()
found = true
} else {
missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") }
runCatching { missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") } }
.onFailure { Log.w(TAG, "${rom.name}: miss marker not written: $it") }
}
}
return found
@@ -50,6 +50,7 @@ data class GlassesState(
val noSignal: Boolean = false,
val sbsSupport: Int = SBS_UNKNOWN,
val sbs90: Boolean = false,
val rates2d: List<Int> = listOf(60, 90, 120),
val experimental: Boolean = false,
val supportsScreen3dof: Boolean = false,
val supports30Hz: Boolean = false,
@@ -613,12 +614,13 @@ object Glasses {
switching3d = modeState.pendingStereo,
brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8,
sunblock = if (m === XrealModel) it.sunblock else _settings.value.sunblock ?: it.sunblock,
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel,
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel || m is RokidModel,
hasSunblock = m?.hasSunblock == true,
diagonalFovDeg = m?.diagonalFovDeg ?: 46f,
tracking = it.tracking && link != null,
noSignal = noSignal,
sbs90 = m?.sbs90 == true,
rates2d = m?.rates2d ?: listOf(60, 90, 120),
experimental = m === XrealModel,
supportsScreen3dof = link?.hasImu == true,
supports30Hz = display?.let(::supports30Hz) == true,
@@ -634,7 +636,8 @@ object Glasses {
scheduleModeCheck()
}
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) ||
vid == XrealModel.VENDOR_ID || vid == RokidModel.VENDOR_ID
internal fun supports30Hz(display: Display): Boolean {
val current = display.mode
@@ -654,11 +657,13 @@ object Glasses {
?: VitureProtocol.model(device.productId, name)
}
else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel
else RayNeoModel.model(device.vendorId, device.productId, name)
else RokidModel.model(device.vendorId, device.productId, name)
?: RayNeoModel.model(device.vendorId, device.productId, name)
}
internal fun isGlassesDisplayName(name: String) =
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name)
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) ||
RokidModel.isDisplay(name)
private fun findDisplay(usbPresent: Boolean): Display? {
return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent)
@@ -723,6 +728,8 @@ object Glasses {
} else VitureLink.open(usb, device, linkListener(generation))
} else if (model === XrealModel) {
XrealLink.open(app, xrealListener(generation))
} else if (model is RokidModel) {
RokidLink.open(usb, device, rokidListener(generation))
} else {
val baseKey = calibrationKey!!
val gtUsb = RayNeoProtocol.isGt(device.vendorId, device.productId)
@@ -790,6 +797,33 @@ object Glasses {
}
}
private fun rokidListener(generation: Long) = object : RokidLink.Listener {
override fun onReady() = post {
if (generation != linkGeneration) return@post
publish { it.copy(connected = true, controlError = null) }
updateImu()
applyMode(force = false)
}
override fun onValue(message: Int, value: Int) = post {
if (generation != linkGeneration) return@post
when (message) {
VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value
VitureProtocol.MSG_BRIGHTNESS -> publish { it.copy(brightness = value) }
}
}
override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) {
if (generation != linkGeneration) return
imuPackets++
probe?.invoke(raw, pose)
if (simulating.get() == null) deliver(pose, timeNs)
}
override fun onLost() = post {
if (generation != linkGeneration) return@post
closeLink()
refresh()
}
}
private fun sdkListener(generation: Long) = object : VitureSdkLink.Listener {
override fun onSpatial(pose: FloatArray, timeNs: Long, valid: Boolean) {
if (generation == linkGeneration) SpatialTracking.accept(pose, timeNs, generation, valid)
@@ -8,6 +8,7 @@ interface GlassesModel {
val volumeMax: Int
val diagonalFovDeg: Float
val sbs90: Boolean
val rates2d: List<Int> get() = listOf(60, 90, 120)
val hasSunblock: Boolean get() = true
val resolutions: List<GlassesResolution> get() = emptyList()
fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = mode(hz, sbs)
@@ -12,8 +12,8 @@ internal object GlassesRendering {
fun refreshRates(settings: GlassesSettings, state: GlassesState): List<Int> = buildList {
if (native30HzAvailable(settings, state)) add(30)
add(60)
if (!state.sbs || state.sbs90) add(90)
if (!state.sbs) add(120)
if (state.sbs) { if (state.sbs90) add(90) }
else addAll(state.rates2d.filter { it > 60 })
}
fun outputHz(settings: GlassesSettings, state: GlassesState): Int {
@@ -21,6 +21,7 @@ internal object GlassesRendering {
return when {
requested == 30 -> if (native30HzAvailable(settings, state)) 30 else 60
state.sbs && !state.sbs90 -> 60
!state.sbs && requested !in state.rates2d -> state.rates2d.filter { it <= requested }.maxOrNull() ?: 60
else -> requested
}
}
@@ -0,0 +1,148 @@
package com.droiddeck.launcher.glasses
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.SystemClock
import android.util.Log
/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */
internal class RokidLink private constructor(
private val connection: UsbDeviceConnection,
private val usbInterface: UsbInterface,
private val endpoint: UsbEndpoint,
private val listener: Listener,
) : GlassesLink {
interface Listener {
fun onReady()
fun onValue(message: Int, value: Int)
fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long)
fun onLost()
}
@Volatile private var running = true
@Volatile override var ready = false
private set
@Volatile private var tracking = false
private val controlLock = Any()
private lateinit var worker: Thread
override val hasImu = true
override fun send(msgId: Int, payload: ByteArray): Boolean {
if (!running) return false
val value = payload.firstOrNull()?.toInt()?.and(0xff) ?: -1
return when (msgId) {
VitureProtocol.MSG_IMU -> (value in 0..1).also { if (it) tracking = value == 1 }
VitureProtocol.MSG_GET_DISPLAY -> display()?.let { listener.onValue(msgId, it) } != null
VitureProtocol.MSG_DISPLAY -> value in RokidModel.MODES &&
control(RokidModel.REQUEST_SET_DISPLAY, value, RokidModel.INDEX_DISPLAY) &&
true.also { listener.onValue(VitureProtocol.MSG_GET_DISPLAY, value) }
VitureProtocol.MSG_GET_BRIGHTNESS -> read(RokidModel.REQUEST_GET_BRIGHTNESS, RokidModel.INDEX_BRIGHTNESS)
?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null
VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS &&
control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS)
else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control.
}
}
private fun display(): Int? = read(RokidModel.REQUEST_GET_DISPLAY, RokidModel.INDEX_DISPLAY)
private fun read(request: Int, index: Int): Int? = synchronized(controlLock) {
if (!running) return null
val buffer = ByteArray(64)
val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS)
// Display mode is reported in the second byte, brightness in the first.
val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0
if (count > offset) buffer[offset].toInt() and 0xff else null
}
private fun control(request: Int, value: Int, index: Int): Boolean = synchronized(controlLock) {
running && connection.controlTransfer(VENDOR_OUT, request, value, index, byteArrayOf(1), 1, TIMEOUT_MS) >= 0
}
private fun run() {
val buffer = ByteArray(maxOf(endpoint.maxPacketSize, 64))
val parser = RokidProtocol.Parser()
var fusion = ImuFusion()
var wasTracking = false
var brightness = -1
var lastSensor = SystemClock.elapsedRealtime()
try {
ready = true
listener.onReady()
while (running) {
val count = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
val now = SystemClock.elapsedRealtime()
val sample = if (count > 0) parser.parse(buffer, count) else null
if (sample == null) {
if (now - lastSensor > 3000) error("Rokid IMU stream stalled")
continue
}
lastSensor = now
sample.brightness?.takeIf { it != brightness }?.let {
brightness = it
listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it))
}
val enabled = tracking
if (enabled && !wasTracking) fusion = ImuFusion()
wasTracking = enabled
if (!enabled) continue
// Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ.
fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let {
if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos())
}
}
} catch (e: Exception) {
if (running) Log.w(TAG, "Rokid stream failed", e)
} finally {
ready = false
if (running) listener.onLost()
}
}
override fun close() {
synchronized(controlLock) {
if (!running) return
running = false
}
if (Thread.currentThread() !== worker) worker.join(500)
runCatching { connection.releaseInterface(usbInterface) }
connection.close()
}
companion object {
private const val TAG = "RokidLink"
private const val TIMEOUT_MS = 250
private const val VENDOR_IN = UsbConstants.USB_DIR_IN or UsbConstants.USB_TYPE_VENDOR
private const val VENDOR_OUT = UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_VENDOR
internal fun sensorEndpoint(device: UsbDevice): Pair<UsbInterface, UsbEndpoint>? {
for (i in 0 until device.interfaceCount) {
val iface = device.getInterface(i)
if (iface.interfaceClass != UsbConstants.USB_CLASS_HID) continue
for (e in 0 until iface.endpointCount) {
val endpoint = iface.getEndpoint(e)
if (endpoint.direction == UsbConstants.USB_DIR_IN &&
endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT) return iface to endpoint
}
}
return null
}
fun open(usb: UsbManager, device: UsbDevice, listener: Listener): RokidLink? {
val (iface, endpoint) = sensorEndpoint(device) ?: return null
val conn = usb.openDevice(device) ?: return null
if (!conn.claimInterface(iface, true)) {
conn.close()
return null
}
return RokidLink(conn, iface, endpoint, listener).also { link ->
link.worker = Thread({ link.run() }, "rokid-imu").apply { isDaemon = true }
link.worker.start()
}
}
}
}
@@ -0,0 +1,104 @@
package com.droiddeck.launcher.glasses
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Rokid Air/Max share one USB identity; the product string distinguishes them. */
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
override val controllable = true
override val brightnessMax = BRIGHTNESS_STEPS
override val volumeMax = 0
override val hasSunblock = false
override val diagonalFovDeg = if (max) 50f else 43f
override val sbs90 = max
override val rates2d = if (max) listOf(60, 120) else listOf(60)
override val resolutions = if (max) listOf(GlassesResolution(1920, 1080), GlassesResolution(1920, 1200))
else listOf(GlassesResolution(1920, 1080))
override fun mode(hz: Int, sbs: Boolean): Int = mode(hz, sbs, null)
override fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = when {
!max -> if (sbs) MODE_SBS_1080_60 else MODE_2D_60
sbs && resolution?.height == 1080 -> MODE_SBS_1080_60
sbs -> if (hz >= 90) MODE_SBS_1200_90 else MODE_SBS_1200_60
hz >= 120 -> MODE_2D_120
else -> MODE_2D_60
}
override fun modeForStep(step: Int): Int? = null
override fun equals(other: Any?) = other is RokidModel && other.name == name && other.max == max
override fun hashCode() = name.hashCode()
companion object {
const val VENDOR_ID = 0x04d2
const val PRODUCT_ID = 0x162f
const val BRIGHTNESS_STEPS = 10
// Vendor control requests, device recipient.
const val REQUEST_SET_DISPLAY = 0x01
const val REQUEST_GET_DISPLAY = 0x81
const val REQUEST_SET_BRIGHTNESS = 0x02
const val REQUEST_GET_BRIGHTNESS = 0x82
const val INDEX_DISPLAY = 0x01
const val INDEX_BRIGHTNESS = 0x02
const val MODE_2D_60 = 0
const val MODE_SBS_1080_60 = 1
const val MODE_2D_120 = 3
const val MODE_SBS_1200_90 = 4
const val MODE_SBS_1200_60 = 5
val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60)
// 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (vid != VENDOR_ID || pid != PRODUCT_ID) return null
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
return RokidModel(product, product.contains("Max", ignoreCase = true))
}
fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true)
/** Device brightness is 0..100; the shared slider uses ten steps. */
fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS)
fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10
}
}
internal object RokidProtocol {
data class Sample(val timeNs: Long, val accel: Vec3, val gyro: Vec3, val brightness: Int?)
private const val COMBINED = 17
private const val SEPARATE = 4
private const val SENSOR_ACCEL = 1
private const val SENSOR_GYRO = 2
/** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */
class Parser {
private var accel: Vec3? = null
private var accelTime = -1L
private var gyro: Vec3? = null
private var gyroTime = -2L
fun parse(packet: ByteArray, length: Int = packet.size): Sample? {
if (length < 64) return null
val b = ByteBuffer.wrap(packet, 0, length).order(ByteOrder.LITTLE_ENDIAN)
return when (packet[0].toInt() and 0xff) {
COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff)
SEPARATE -> {
val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds.
when (packet[1].toInt() and 0xff) {
SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time }
SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time }
else -> return null
}
if (accelTime != gyroTime) return null
Sample(time, accel!!, gyro!!, null).also { accelTime = -1L; gyroTime = -2L }
}
else -> null
}
}
private fun vec(b: ByteBuffer, offset: Int) = Vec3(b.getFloat(offset), b.getFloat(offset + 4), b.getFloat(offset + 8))
}
}
@@ -4,20 +4,27 @@ import kotlin.math.abs
/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
internal class XrealOrientationFilter {
private val fusion = ImuFusion()
// 1S reports right/down/forward. Rotate 180 degrees about X into the
// shared right/up/back basis before either gravity alignment or fusion.
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs,
Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z),
Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z))
}
/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */
internal class ImuFusion {
private var previous = 0L
private var rotation = Quaternion.IDENTITY
private var initialized = false
private var bias = Vec3(0f, 0f, 0f)
fun update(sample: XrealProtocol.Sample): FloatArray? {
if (sample.timeNs <= previous) return null
val dt = if (previous == 0L) 0f else (sample.timeNs - previous) * 1e-9f
previous = sample.timeNs
// 1S reports right/down/forward. Rotate 180 degrees about X into the
// shared right/up/back basis before either gravity alignment or fusion.
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
val accel = Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z)
val gyro = Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z)
fun update(timeNs: Long, accel: Vec3, gyro: Vec3): FloatArray? {
if (timeNs <= previous) return null
val dt = if (previous == 0L) 0f else (timeNs - previous) * 1e-9f
previous = timeNs
val norm = accel.length()
val gravityValid = abs(norm - 9.81f) < 1.5f
if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion.
@@ -28,7 +28,7 @@ object AutoDriverTrial {
release, p.getString("name", "").orEmpty(),
p.getString("display", "").orEmpty(), p.getString("linux", "").orEmpty(),
p.getString("previousDisplay", "").orEmpty(), p.getString("previousLinux", "").orEmpty(),
p.getInt("starts", 0),
maxOf(p.getInt("starts", 0), p.getInt("displayStarts", 0)),
)
}
@@ -37,7 +37,7 @@ object AutoDriverTrial {
.putString("release", release).putString("name", name)
.putString("display", display).putString("linux", linux)
.putString("previousDisplay", previousDisplay).putString("previousLinux", previousLinux)
.putInt("starts", 0)
.putInt("starts", 0).putInt("displayStarts", 0)
.commit()
}
@@ -48,13 +48,24 @@ object AutoDriverTrial {
fun bad(context: Context): Set<String> = prefs(context).getStringSet("bad", emptySet()).orEmpty()
/**
* Counted before the compositor loads the trial display driver, so a driver that kills the process during
* Vulkan init still counts towards a rollback; sessionStarting runs too late for that.
*/
fun displayStarting(context: Context, display: String) {
val p = prefs(context)
if (p.getString("release", null) == null || p.getString("display", "") != display) return
val counted = maxOf(p.getInt("displayStarts", 0), p.getInt("starts", 0))
p.edit().putInt("displayStarts", counted + 1).commit()
}
fun sessionStarting(context: Context, display: String, linux: String) {
val trial = current(context)
watching = null
if (trial == null || trial.display != display || trial.linux != linux) return
app = context.applicationContext
watching = trial
prefs(context).edit().putInt("starts", trial.starts + 1).commit()
prefs(context).edit().putInt("starts", prefs(context).getInt("starts", 0) + 1).commit()
}
fun sessionReady() {
@@ -99,7 +99,8 @@ public class FakeInputWriter {
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 98;
private static final int TRITON_OUTPUT_ACK_OFFSET = TRITON_OFFSET + 100;
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
@@ -229,8 +230,9 @@ public class FakeInputWriter {
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
data.putShort(TRITON_OUTPUT_FEATURE_OFFSET, (short) 0);
data.putShort(TRITON_OUTPUT_LENGTH_OFFSET, (short) 0);
data.putInt(TRITON_OUTPUT_ACK_OFFSET, 0);
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
}
@@ -587,14 +589,18 @@ public class FakeInputWriter {
nativeStoreFence();
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
if ((seq & 1) != 0 || seq <= lastSequence) return null;
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
int length = ring.getShort(TRITON_OUTPUT_LENGTH_OFFSET) & 0xffff;
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
byte[] output = new byte[9 + length];
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
output[8] = (byte) (ring.getShort(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
nativeStoreFence();
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
if (seq != ring.getLong(TRITON_OUTPUT_SEQ_OFFSET)) return null;
// Acknowledge the exact report sequence taken so a concurrent timeout overwrite cannot lose its length.
ring.putInt(TRITON_OUTPUT_ACK_OFFSET, (int) seq);
nativeStoreFence();
return output;
}
}
@@ -55,6 +55,7 @@ public final class PadBridge {
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
private volatile boolean selectSteam;
private volatile boolean builtinPad = true;
private TouchRoute touchRoute;
private final PadState routedTouch = new PadState();
private boolean systemGuidePressed;
@@ -110,6 +111,25 @@ public final class PadBridge {
excluded = devices;
}
public synchronized void setBuiltinPad(boolean on) {
boolean changed = builtinPad != on;
builtinPad = on;
if (changed && !on) resetBuiltin();
}
private void resetBuiltin() {
routedTouch.clear();
systemGuidePressed = false;
systemQamPressed = false;
qamTapPressed = false;
qamChordActive = false;
chordButtonPressed = false;
qamChordGeneration++;
chordHandler.removeCallbacksAndMessages(null);
state.clear();
publish();
}
public synchronized boolean start() {
if (!open) {
open = writer.open();
@@ -373,6 +393,7 @@ public final class PadBridge {
}
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
if (!builtinPad) return;
if (touchRoute != null && touchRoute.active()) {
mutation.accept(routedTouch);
touchRoute.accept(routedTouch);
@@ -412,6 +433,7 @@ public final class PadBridge {
}
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
if (!builtinPad) return;
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
@@ -443,6 +465,7 @@ public final class PadBridge {
}
public synchronized void triggerQam() {
if (!builtinPad) return;
if (!nativeQam()) {
startChord(PadState.A);
return;
@@ -461,6 +484,7 @@ public final class PadBridge {
}
public synchronized void triggerSteamKeyboard() {
if (!builtinPad) return;
startChord(PadState.X);
}
@@ -632,6 +656,7 @@ public final class PadBridge {
}
private void publish() {
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
if (!open && !start()) {
statDropped++;
return;
@@ -211,7 +211,7 @@ object SteamTouchBindings {
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
fun gameIconsDir(context: Context, appId: Int): File? = try {
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_${SteamTouchConfig.steamId(appId)}.acf")
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
} catch (_: Exception) {
@@ -126,7 +126,11 @@ object SteamTouchConfig {
}
}
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
private val DEFAULT_SET = setOf(
JOYSTICK_LEFT, JOYSTICK_LEFT_BUTTON, JOYSTICK_RIGHT, JOYSTICK_RIGHT_BUTTON,
DPAD, A, B, X, Y, SELECT, START,
BUMPER_LEFT, BUMPER_RIGHT, TRIGGER_LEFT, TRIGGER_RIGHT,
)
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
fun steamId(appId: Int): String = Integer.toUnsignedString(appId)
@@ -301,9 +301,8 @@ class SteamTouchControls(
}
}
SteamTouchConfig.STEAM -> {
if (pressed) canvas.drawCircle(x, y, r, fill)
canvas.drawCircle(x, y, r, stroke)
drawMark(canvas, x, y, r * 0.5f, if (pressed) Color.argb(alpha, 23, 26, 33) else ink, alpha)
if (isSelected) canvas.drawCircle(x, y, r, stroke)
drawMark(canvas, x, y, r * 0.5f, if (pressed) accent else ink, alpha)
}
SteamTouchConfig.SELECT, SteamTouchConfig.START -> {
val binding = visuals[e.type]?.firstOrNull()?.binding
@@ -0,0 +1,58 @@
package com.droiddeck.launcher.input
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
/** What SteamVR sees for the game pad. The driver reads the file once, when SteamVR starts. */
object VrControllers {
enum class Emulate(val key: String) { OFF("off"), INDEX("index"), TOUCH("touch"), VIVE("vive") }
data class Settings(val emulate: Emulate = Emulate.INDEX, val sticksMoveHands: Boolean = false)
const val FILE = "droiddeck-vr-controllers"
private const val PREFS = "vr_controllers"
private val _settings = MutableStateFlow(Settings())
val settings: StateFlow<Settings> = _settings.asStateFlow()
private var loaded = false
fun load(context: Context): Settings {
if (!loaded) {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
_settings.value = Settings(
emulate = Emulate.entries.firstOrNull { it.key == p.getString("emulate", null) } ?: Emulate.INDEX,
sticksMoveHands = p.getBoolean("sticksMoveHands", false),
)
loaded = true
}
return _settings.value
}
fun setEmulate(context: Context, emulate: Emulate) = save(context, load(context).copy(emulate = emulate))
fun setSticksMoveHands(context: Context, on: Boolean) = save(context, load(context).copy(sticksMoveHands = on))
private fun save(context: Context, settings: Settings) {
_settings.value = settings
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("emulate", settings.emulate.key)
.putBoolean("sticksMoveHands", settings.sticksMoveHands)
.apply()
publish(context)
}
fun encode(settings: Settings): String =
"emulate=${settings.emulate.key}\nsticks=${if (settings.sticksMoveHands) "hands" else "game"}\n"
/** Writes into the session's runtime dir, which the guest sees as /run/droiddeck. */
fun publish(context: Context) {
runCatching {
val dir = File(context.filesDir, ".wayland-rt").apply { mkdirs() }
val tmp = File(dir, "$FILE.tmp")
tmp.writeText(encode(load(context)))
tmp.renameTo(File(dir, FILE))
}
}
}
@@ -181,6 +181,7 @@ object BwrapSpawner {
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
preload(session, rootfs, env).forEach { addBind(it) }
zypakShims(binds, root, dir).forEach { addBind(it) }
browserSandboxes(env)
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
}
@@ -227,6 +228,20 @@ object BwrapSpawner {
private const val PRELOAD_DIR = "/run/droiddeck-preload"
private const val ZYPAK_SHIM = "#!/bin/sh\nprogram=\$1\nshift\nexec \"\$program\" --no-sandbox \"\$@\"\n"
private fun zypakShims(binds: List<Bind>, root: File, dir: File): List<Bind> {
val wrappers = listOf("/app/bin/zypak-wrapper.sh", "/app/bin/zypak-wrapper").filter { guest ->
val host = File(hostPath(binds, root.path, guest))
host.isFile && !Files.isSymbolicLink(host.toPath())
}
if (wrappers.isEmpty()) return emptyList()
val shim = File(dir, "zypak-wrapper")
shim.writeText(ZYPAK_SHIM)
chmod(shim, 0x1ed)
return wrappers.map { Bind(shim.path, it) }
}
private fun browserSandboxes(env: MutableMap<String, String>) {
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
@@ -22,6 +22,7 @@ object DeckyManager {
private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30"
private const val PATH = "root/homebrew/services/PluginLoader"
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val SETTINGS = "root/homebrew/settings/loader.json"
// droiddeck-session reads the marker from the root of the client it runs.
private val CEF_REMOTE_DEBUG_MARKERS = listOf(
"root/.local/share/Steam/.cef-enable-remote-debugging",
@@ -32,7 +33,34 @@ object DeckyManager {
private set
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>, val all: List<Release> = stable + prerelease)
const val BRANCH_STABLE = 0
const val BRANCH_PRERELEASE = 1
const val BRANCH_TESTING = 2
private fun settingsFile(context: Context) = File(LinuxRuntime.rootDir(context), SETTINGS)
fun branch(context: Context): Int {
val saved = runCatching { JSONObject(settingsFile(context).readText()).optInt("branch", -1) }.getOrDefault(-1)
if (saved >= 0) return saved
val version = File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim().orEmpty()
return if (version.startsWith("v") && "-pre" in version) BRANCH_PRERELEASE else BRANCH_STABLE
}
fun setBranch(context: Context, branch: Int) {
val file = settingsFile(context)
val settings = runCatching { JSONObject(file.readText()) }.getOrDefault(JSONObject())
settings.put("branch", branch)
file.parentFile?.mkdirs()
val staged = File(file.parentFile, "${file.name}.tmp")
staged.writeText(settings.toString(4))
check(staged.renameTo(file)) { "cannot write ${file.path}" }
}
// Mirrors Decky's updater: Stable takes the newest non-prerelease tag, Prerelease the newest tag of either kind.
fun latest(channels: ReleaseChannels, branch: Int): Release? =
if (branch == BRANCH_STABLE) channels.stable.firstOrNull() else channels.all.firstOrNull()
fun installed(context: Context): String? {
val loader = loader(context)
@@ -74,17 +102,13 @@ object DeckyManager {
}
}
fun releases(context: Context, prerelease: Boolean): List<Release> {
val channels = releaseChannels(context)
return if (prerelease) channels.prerelease else channels.stable
}
fun releaseChannels(context: Context): ReleaseChannels {
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
return try {
val array = JSONArray(body)
val stable = mutableListOf<Release>()
val prerelease = mutableListOf<Release>()
val all = mutableListOf<Release>()
for (i in 0 until array.length()) {
val release = array.getJSONObject(i)
if (release.optBoolean("draft", false)) continue
@@ -105,9 +129,11 @@ object DeckyManager {
val size = binary.optLong("size", 0)
if (size <= 0) continue
val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62)
(if (isPrerelease) prerelease else stable).add(row)
if (!row.tag.startsWith("v")) continue
all.add(row)
(if (isPrerelease || "-pre" in row.tag) prerelease else stable).add(row)
}
ReleaseChannels(stable, prerelease)
ReleaseChannels(stable, prerelease, all)
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
}
@@ -104,7 +104,6 @@ class LinuxNetworkLinkComponent(
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
}
}
wifiReceiver = receiver
appContext.registerReceiver(receiver, IntentFilter().apply {
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
addAction(WifiManager.RSSI_CHANGED_ACTION)
@@ -112,6 +111,7 @@ class LinuxNetworkLinkComponent(
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
addAction(LocationManager.MODE_CHANGED_ACTION)
})
wifiReceiver = receiver
scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified()
scanHandler.post(scanRequests)
requestWifiScan()
@@ -119,7 +119,13 @@ class LinuxNetworkLinkComponent(
override fun stop() {
scanHandler.removeCallbacks(scanRequests)
wifiReceiver?.let { appContext.unregisterReceiver(it) }
wifiReceiver?.let { receiver ->
try {
appContext.unregisterReceiver(receiver)
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Wi-Fi receiver was already gone", e)
}
}
wifiReceiver = null
val registered = synchronized(lock) { callback.also { callback = null } } ?: return
try {
@@ -25,6 +25,8 @@ import java.io.FileOutputStream
object ComponentsManager {
private const val TAG = "Components"
private const val NIGHTLIES = "The412Banner/Nightlies"
private const val DD_COMPONENTS = "Droid-Deck/DroidDeck-Components"
internal const val DD_NIGHTLY = "linux-nightly"
private val RELEASES = linkedMapOf(
"FexCore-Linux" to "fex",
"Dxvk-Linux" to "dxvk",
@@ -32,6 +34,10 @@ object ComponentsManager {
"Dxvk-gplasync-Linux" to "dxvk",
"Vkd3d-proton-Linux" to "vkd3d",
)
// DD_NIGHTLY holds every component in one release; the file name says which.
private val NIGHTLY_PREFIXES = linkedMapOf("DD-dxvk-" to "dxvk", "DD-vkd3d-proton-" to "vkd3d", "DD-FEXCore-" to "fex")
private val SOURCES = RELEASES.keys.map { NIGHTLIES to it } + (DD_COMPONENTS to DD_NIGHTLY)
private val DOWNLOAD_REPOS = listOf(NIGHTLIES, DD_COMPONENTS)
private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d")
private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux")
val COMPONENTS = listOf("fex", "dxvk", "vkd3d")
@@ -815,14 +821,15 @@ object ComponentsManager {
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
for ((tag, comp) in RELEASES) {
val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue
for ((repo, tag) in SOURCES) {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
if (!name.endsWith(".wcp")) continue
if (!Hashes.isGithubSha256(a.optString("digest"))) continue
val comp = assetComp(tag, name) ?: continue
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
@@ -837,10 +844,32 @@ object ComponentsManager {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
}.sortedWith(compareBy<CatalogItem> { it.comp }.then(newestFirst { it.file }))
}.sortedWith(CATALOG_ORDER)
return Catalog(items, o.optLong("fetchedAt"))
}
internal fun assetComp(release: String, name: String): String? =
RELEASES[release] ?: if (release == DD_NIGHTLY) NIGHTLY_PREFIXES.entries.firstOrNull { name.startsWith(it.key) }?.value else null
// Per component: the stable packages newest first, then the nightlies.
internal val CATALOG_ORDER: Comparator<CatalogItem> =
compareBy<CatalogItem> { it.comp }.thenBy { it.release == DD_NIGHTLY }.then(newestFirst { it.file })
// A nightly DD-<flavor>-<version>[+N]-nightly.wcp pairs with the newest stable <flavor>-<version>[-<rev>]-linux.wcp
// in the same list, so the stable row can offer it behind a Stable/Nightly switch. Unpaired nightlies keep their own row.
internal fun nightlyPairs(items: List<CatalogItem>): Map<CatalogItem, CatalogItem> {
val stable = items.filter { it.release != DD_NIGHTLY }
return items.filter { it.release == DD_NIGHTLY }.mapNotNull { n ->
val base = n.file.removePrefix("DD-").removeSuffix("-nightly.wcp").substringBefore('+')
val rev = Regex(Regex.escape(base) + "(-\\d+)?-linux\\.wcp")
stable.filter { it.comp == n.comp && rev.matches(it.file) }
.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a.file, b.file) }?.let { it to n }
}.groupBy({ it.first }, { it.second })
.mapValues { (_, ns) -> ns.maxWith { a, b -> ProtonExtras.compareVersions(a.file, b.file) } }
}
internal fun allowedDownload(url: String) = DOWNLOAD_REPOS.any { url.startsWith("https://github.com/$it/releases/download/") }
private fun <T> newestFirst(of: (T) -> String): Comparator<T> =
Comparator { a, b -> ProtonExtras.compareVersions(of(b), of(a)) }
@@ -925,11 +954,13 @@ object ComponentsManager {
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { context.getString(R.string.cmgr_nightlies_only) }
require(allowedDownload(item.url)) { context.getString(R.string.cmgr_nightlies_only) }
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) {
context.getString(if (item.release == DD_NIGHTLY) R.string.cmgr_nightly_replaced else R.string.user_apps_download_failed)
}
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
@@ -1,6 +1,9 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Environment
import android.os.Handler
import android.os.Looper
@@ -15,21 +18,35 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
class PerfHud(context: Context) {
var text by mutableStateOf("")
private set
var graph by mutableStateOf(FloatArray(0))
private set
private val context = context.applicationContext
private val frames = AtomicInteger()
private var lastTick = 0L
private var lastFps = 0f
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val intervals = FloatArray(GRAPH)
private val graphWrite = AtomicInteger()
private val lastFrameNs = AtomicLong()
@Volatile private var showFps = true
@Volatile private var showBattery = true
@Volatile private var showGraph = true
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context)
&& !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
private val anyPart: Boolean
get() = showFps || showBattery || showGraph
var onPresentingWindowChanged: (() -> Unit)? = null
private var lastWindow: String? = null
@@ -40,7 +57,17 @@ class PerfHud(context: Context) {
handler.post { onPresentingWindowChanged?.invoke() }
}
}
override fun onGameFrame() { frames.incrementAndGet() }
override fun onGameFrame() {
frames.incrementAndGet()
if (!showGraph) return
val now = System.nanoTime()
val previous = lastFrameNs.getAndSet(now)
if (previous == 0L) return
val ms = ((now - previous) / 1_000_000f).coerceIn(0f, 50f)
val slot = graphWrite.get()
intervals[slot and (GRAPH - 1)] = ms
graphWrite.compareAndSet(slot, slot + 1)
}
}
private val tick = object : Runnable {
@@ -49,28 +76,82 @@ class PerfHud(context: Context) {
val now = SystemClock.elapsedRealtime()
val dt = (now - lastTick).coerceAtLeast(1L) / 1000f
lastTick = now
text = line(frames.getAndSet(0) / dt)
handler.postDelayed(this, 1000)
lastFps = frames.getAndSet(0) / dt
text = readout(lastFps)
if (showGraph) publishGraph() else clearGraph()
handler.postDelayed(this, TICK_MS)
}
}
fun refresh() {
if (enabled) start() else stop()
readOptions()
if (!enabled || !anyPart) {
stop()
return
}
if (!running) start() else {
if (!showGraph) clearGraph() else publishGraph()
text = readout(lastFps)
}
}
fun start() {
if (!enabled || running) return
readOptions()
if (!enabled || !anyPart || running) return
running = true
lastTick = SystemClock.elapsedRealtime()
lastFrameNs.set(0L)
frames.set(0)
graphWrite.set(0)
WaylandCompositor.setGameListener(listener)
handler.post(tick)
handler.postDelayed(tick, TICK_MS)
}
fun stop() {
running = false
handler.removeCallbacks(tick)
WaylandCompositor.clearGameListener(listener)
text = ""
graph = FloatArray(0)
}
private fun readOptions() {
showFps = SessionPrefs.hudShowFps(context)
showBattery = SessionPrefs.hudShowBattery(context)
showGraph = SessionPrefs.hudShowGraph(context)
}
private fun readout(fpsValue: Float): String {
val parts = ArrayList<String>(2)
if (showFps) parts.add(line(fpsValue))
if (showBattery) batteryCelsius(readBatteryDeci())?.let { parts.add(it) }
return parts.joinToString("\n")
}
private fun readBatteryDeci(): Int {
val sticky = runCatching {
context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}.getOrNull() ?: return 0
return sticky.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0)
}
private fun clearGraph() {
lastFrameNs.set(0L)
graphWrite.set(0)
if (graph.isNotEmpty()) graph = FloatArray(0)
}
private fun publishGraph() {
val count = graphWrite.get()
val shown = minOf(count, GRAPH)
if (shown < 2) {
graph = FloatArray(0)
return
}
val out = FloatArray(shown)
val start = count - shown
for (i in 0 until shown) out[i] = intervals[(start + i) and (GRAPH - 1)]
graph = out
}
private fun line(base: Float): String {
@@ -91,4 +172,12 @@ class PerfHud(context: Context) {
}
private fun fps(value: Float) = String.format(Locale.US, "%.0f fps", value)
companion object {
private const val TICK_MS = 1_000L
private const val GRAPH = 128
internal fun batteryCelsius(deci: Int): String? =
if (deci <= 0) null else String.format(Locale.US, "%.1f°C", deci / 10f)
}
}
@@ -82,8 +82,10 @@ object SessionFiles {
"usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper",
"usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper",
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
"usr/local/bin/droiddeck-pipewire" to "usr/local/bin/droiddeck-pipewire",
"usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings",
"usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game",
"usr/local/bin/droiddeck-steamvr-flat" to "usr/local/bin/droiddeck-steamvr-flat",
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
@@ -137,6 +139,7 @@ object SessionFiles {
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
@@ -21,12 +21,14 @@ object SessionPrefs {
const val SUSPEND_NEVER = "never"
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val CONTROLLER_STEAM = "steamcontroller"
const val CONTROLLER_DS4 = "dualshock4"
const val CONTROLLER_DUALSENSE = "dualsense"
const val CONTROLLER_NONE = "none"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
@@ -49,9 +51,9 @@ object SessionPrefs {
)
fun orientation(context: Context): Int =
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
.takeIf { value -> orientationOptions.any { it.first == value } }
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
?: ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
fun setOrientation(context: Context, orientation: Int) {
require(orientationOptions.any { it.first == orientation })
@@ -144,6 +146,13 @@ object SessionPrefs {
prefs(context).edit().putString("storesInstallTarget", path).apply()
}
/** Empty means follow the app/system language for GOG and Epic installs. */
fun storesInstallLanguage(context: Context): String = prefs(context).getString("storesInstallLanguage", "") ?: ""
fun setStoresInstallLanguage(context: Context, code: String) {
prefs(context).edit().putString("storesInstallLanguage", code).apply()
}
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
fun setGameStoresParallel(context: Context, count: Int) {
@@ -156,6 +165,43 @@ object SessionPrefs {
prefs(context).edit().putBoolean(hudKey(context), on).apply()
}
fun hudShowFps(context: Context): Boolean = prefs(context).getBoolean("hudFps", true)
fun setHudShowFps(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hudFps", on).apply()
}
fun hudShowBattery(context: Context): Boolean = prefs(context).getBoolean("hudBattery", true)
fun setHudShowBattery(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hudBattery", on).apply()
}
fun hudShowGraph(context: Context): Boolean = prefs(context).getBoolean("hudGraph", true)
fun setHudShowGraph(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hudGraph", on).apply()
}
fun hudColor(context: Context): String =
prefs(context).getString("hudColor", HUD_COLOR_MINT)?.takeIf { it in HUD_COLORS } ?: HUD_COLOR_MINT
fun setHudColor(context: Context, color: String) {
if (color in HUD_COLORS) prefs(context).edit().putString("hudColor", color).apply()
}
fun hudOpacity(context: Context): Int =
prefs(context).getInt("hudOpacity", HUD_OPACITY_DEFAULT).takeIf { it in HUD_OPACITY_RANGE } ?: HUD_OPACITY_DEFAULT
fun setHudOpacity(context: Context, percent: Int) {
prefs(context).edit().putInt("hudOpacity", percent.coerceIn(HUD_OPACITY_RANGE)).apply()
}
const val HUD_COLOR_MINT = "mint"
val HUD_COLORS = listOf(HUD_COLOR_MINT, "white", "sky", "amber", "violet")
val HUD_OPACITY_RANGE = 30..100
const val HUD_OPACITY_DEFAULT = 70
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
private fun mangoappSession(context: Context) =
@@ -249,6 +295,16 @@ object SessionPrefs {
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
}
/**
* After a game launched from the front end (or another launcher's link) closes, the client
* exits cleanly and the session returns there, instead of staying in Big Picture.
*/
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
fun setReturnAfterGame(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("returnAfterGame", on).apply()
}
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
@@ -420,6 +476,8 @@ object SessionPrefs {
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
fun builtinPad(context: Context): Boolean = steamController(context) != CONTROLLER_NONE
fun padType(id: String): Int = when (id) {
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
@@ -953,6 +1011,12 @@ object SessionPrefs {
}.apply()
}
fun deckyZipWarningSeen(context: Context): Boolean = prefs(context).getBoolean(DECKY_ZIP_WARNING_SEEN, false)
fun setDeckyZipWarningSeen(context: Context) {
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
}
fun steamDownloadsInBackground(context: Context): Boolean {
val prefs = prefs(context)
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
@@ -71,6 +71,7 @@ class SessionService : Service() {
private val steamDownloadMonitor = SteamDownloadMonitor()
private var downloadMonitorTask: Runnable? = null
private var downloadActive = false
@Volatile private var steamLibraryMounts = emptyMap<String, File>()
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
@@ -92,6 +93,8 @@ class SessionService : Service() {
private var suspendAttemptFailed = false
private var pendingSteamGameId: String? = null
private var pendingSteamGameGeneration = -1
// Bumped to cancel a running game-exit watch (a new launch, or the session ending).
@Volatile private var gameExitWatchToken = 0
private val flushSteamGame = object : Runnable {
override fun run() = flushQueuedSteamGame()
}
@@ -309,6 +312,8 @@ class SessionService : Service() {
val gen = ++sessionGen
cancelQueuedSteamGame(clearMailbox = false)
clearSteamGameMailbox()
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl
?.takeIf { it.startsWith(GAME_URL_PREFIX) }?.let { watchGameExit(it.removePrefix(GAME_URL_PREFIX), gen) }
acquireLocks()
Thread({
OrphanReaper.reap("session starting")
@@ -394,6 +399,12 @@ class SessionService : Service() {
val fastPathAt = guest.size
val pulse = startAudio(guest, sessionDir)
if (steamHere) {
val encoder = VideoEncodeBridge(File(filesDir, "venc/venc.sock"))
encoder.attach(this)
components.add(encoder)
guest.add("BL_VENC_SOCKET=" + encoder.socket.absolutePath)
}
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
@@ -702,17 +713,21 @@ class SessionService : Service() {
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File, sessionDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
val builtinPad = SessionPrefs.builtinPad(this)
val hiddenPad = SessionState.mode == MODE_STEAM && !builtinPad
fakeInputDir.listFiles()?.forEach { file ->
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD) ||
(hiddenPad && node == 0)) file.delete()
}
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
val presented = if (hiddenPad) rings?.split(';')?.filterNot { it.startsWith("0=") }?.joinToString(";") else rings
if (!presented.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$presented")
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
val wantsDeck = uinput && builtinPad && SessionState.mode == MODE_STEAM &&
SessionState.steamUi != "desktop" &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
@@ -802,6 +817,7 @@ class SessionService : Service() {
File(home, "Storage").mkdirs()
binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage")
val library = GameStorage.effective(this)
steamLibraryMounts = emptyMap()
var storageDiagnosticLibrary: File? = null
if (library != null) {
val problem = GameStorage.prepare(this, library.path)
@@ -813,6 +829,7 @@ class SessionService : Service() {
val content = SecondaryLibrary.contentPath(configured)
binds.addAll(SecondaryLibrary.binds(filesDir, configured, content = content))
storageDiagnosticLibrary = content
steamLibraryMounts = SecondaryLibrary.CARD_GUESTS.associateWith { content }
val route = if (content === configured) "configured" else "verified-native"
Log.i(TAG, "game storage: ${content.path} ($route) -> /mnt/droiddeck-sd (\"${library.label}\"); prefixes and native tools private")
} catch (e: Exception) {
@@ -1071,9 +1088,71 @@ class SessionService : Service() {
pendingSteamGameId = null
pendingSteamGameGeneration = -1
SessionEvents.record("steam.game_launch_requested", mapOf("gameId" to gameId, "generation" to gen))
watchGameExit(gameId, gen)
Log.i(TAG, "queued Steam game $gameId for the running client")
}
/**
* A game launched from the front end (the Games tab, or another launcher's link): once it has
* started and then been gone for [GAME_EXIT_GRACE_MS], ask the client to exit through the
* session script's steam-stop request. The client quits cleanly, finishing its cloud sync, so
* the session ends on its own and the activity returns to where the launch came from instead
* of leaving the player in Big Picture. The grace covers launchers that restart the game.
*/
private fun watchGameExit(gameId: String, gen: Int) {
if (!SessionPrefs.returnAfterGame(this)) return
val token = ++gameExitWatchToken
Thread({
val startBy = android.os.SystemClock.elapsedRealtime() + GAME_START_TIMEOUT_MS
var seen = false
var goneSince = 0L
while (token == gameExitWatchToken && gen == sessionGen && SessionState.running && !SessionState.stopRequested) {
val now = android.os.SystemClock.elapsedRealtime()
if (steamGameRunning()) {
if (!seen) Log.i(TAG, "game $gameId started; returning to the launcher when it closes")
seen = true
goneSince = 0L
} else if (!seen) {
// Never started (an install, an update or a failed launch): leave the session be.
if (now > startBy) {
Log.i(TAG, "game $gameId did not start; not watching for its exit")
return@Thread
}
} else if (goneSince == 0L) {
goneSince = now
} else if (now - goneSince >= GAME_EXIT_GRACE_MS) {
mainHandler.post { returnAfterGame(gameId, gen, token) }
return@Thread
}
try { Thread.sleep(GAME_EXIT_POLL_MS) } catch (e: InterruptedException) { return@Thread }
}
}, "game-exit-watch").start()
}
private fun returnAfterGame(gameId: String, gen: Int, token: Int) {
if (token != gameExitWatchToken || gen != sessionGen || !SessionState.running ||
SessionState.stopRequested || SessionState.mode != MODE_STEAM || !SessionPrefs.returnAfterGame(this)) return
val written = runCatching { File(LinuxRuntime.sessionRoot(this), "steam-stop").writeText("") }
.onFailure { Log.w(TAG, "could not ask the client to exit after game $gameId", it) }.isSuccess
if (!written) return
SessionEvents.record("steam.return_after_game", mapOf("gameId" to gameId))
Log.i(TAG, "game $gameId closed: asking the client to exit and returning to the launcher")
}
/** A Steam game is running: the client runs each one under its reaper, and Proton's script
* waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */
private fun steamGameRunning(): Boolean {
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
val text = String(cmd).replace('\u0000', ' ')
if (text.contains("SteamLaunch AppId=") || text.contains("waitforexitandrun")) return true
}
return false
}
private fun resumeSession(panelVisible: Boolean = true) {
if (panelVisible) activityVisible = true
screenOn = readScreenOn()
@@ -1271,13 +1350,13 @@ class SessionService : Service() {
private fun updateSuspendPolicy() {
if (!SessionState.running) return
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && !hasVisibleSurface) {
manualPauseRequested = true
}
val controller = suspendController ?: return
val hidden = !hasVisibleSurface
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) {
manualPauseRequested = true
}
val controller = suspendController ?: return
if (downloadsBlockingSuspend) acquireLocks()
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
@@ -1473,6 +1552,7 @@ class SessionService : Service() {
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
gameExitWatchToken++
completeNativeSleep()
val (ended, endedArch) = synchronized(stopLock) {
if (!SessionState.running) return
@@ -1547,7 +1627,6 @@ class SessionService : Service() {
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (rootIntent?.component?.className == com.droiddeck.launcher.ui.SecondScreenActivity::class.java.name) return
if (pipTask || xrActivityVisible) {
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
activityVisible = false
@@ -1712,6 +1791,7 @@ class SessionService : Service() {
LinuxRuntime.rootDir(this@SessionService),
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
client = SessionState.steamClientArch,
mounts = steamLibraryMounts,
)
if (wasActive != downloadActive) refreshNotification()
updateSuspendPolicy()
@@ -1730,7 +1810,9 @@ class SessionService : Service() {
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
}
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this)
else if (SessionState.mode == MODE_STEAM && !SessionPrefs.builtinPad(this)) "one per connected controller, no built-in pad"
else "an Xbox 360 controller")
}
companion object {
@@ -1775,6 +1857,12 @@ class SessionService : Service() {
private const val GAME_LAUNCH_RETRY_MS = 250L
private const val DOWNLOAD_POLL_MS = 2_000L
private const val STEAM_GAME_REQUEST = "steam-game"
private const val GAME_URL_PREFIX = "steam://rungameid/"
/** How long a front-end launch may take to start (downloads, first-run installs) before the exit watch gives up. */
private const val GAME_START_TIMEOUT_MS = 20 * 60_000L
/** How long the game must stay gone before the client is asked to exit: launchers restart games. */
private const val GAME_EXIT_GRACE_MS = 10_000L
private const val GAME_EXIT_POLL_MS = 2_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
@@ -13,7 +13,8 @@ object SettingsReset {
internal val SESSION_KEYS = setOf(
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"hud", "hudDeck", "backActionsInverted", "touch", "osc", "shape", "steamController",
"storesInstallLanguage",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "touch", "osc", "shape", "steamController",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9", "picturePosition",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
@@ -18,8 +18,9 @@ internal class SteamDownloadMonitor {
private var lastReceivedBytes = -1L
private var lastReceivedAt = 0L
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null): Boolean {
val current = manifests(runtimeRoot).associate { it.path to read(it) }
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null,
mounts: Map<String, File> = emptyMap()): Boolean {
val current = manifests(runtimeRoot, mounts).associate { it.path to read(it) }
val progressed = current.any { (path, state) ->
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
}
@@ -98,7 +99,7 @@ internal class SteamDownloadMonitor {
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
}
private fun manifests(runtimeRoot: File): List<File> {
private fun manifests(runtimeRoot: File, mounts: Map<String, File>): List<File> {
val steamRoots = linkedSetOf<File>()
for (home in STEAM_HOMES) {
val primary = File(runtimeRoot, home)
@@ -106,7 +107,7 @@ internal class SteamDownloadMonitor {
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
runCatching {
PATH.findAll(libraryFolders.readText()).forEach { match ->
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
steamRoots += hostPath(runtimeRoot, match.groupValues[1], mounts)
}
}
}
@@ -117,6 +118,15 @@ internal class SteamDownloadMonitor {
}
}
private fun hostPath(runtimeRoot: File, guest: String, mounts: Map<String, File>): File {
val path = guest.trimEnd('/')
for ((mount, host) in mounts) {
if (path == mount) return host
if (path.startsWith("$mount/")) return File(host, path.removePrefix("$mount/"))
}
return File(runtimeRoot, path.removePrefix("/"))
}
private fun read(file: File): ManifestState {
val text = runCatching { file.readText() }.getOrDefault("")
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
@@ -0,0 +1,28 @@
package com.droiddeck.launcher.session
import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import java.io.File
/** Steam's Remote Play V4L2 encoder in the guest talks to this MediaCodec encoder (venc.c, venc_server.cpp). */
class VideoEncodeBridge(val socket: File) : SessionPart() {
override fun start() {
socket.parentFile?.mkdirs()
val ok = runCatching { System.loadLibrary("ddvenc"); nativeStart(socket.absolutePath) }
.onFailure { Log.w(TAG, "hardware video encoding unavailable", it) }
.getOrDefault(false)
Log.i(TAG, if (ok) "Remote Play hardware encoding on at $socket" else "Remote Play hardware encoding off")
}
override fun stop() {
runCatching { nativeStop() }
socket.delete()
}
private external fun nativeStart(path: String): Boolean
private external fun nativeStop()
private companion object {
const val TAG = "VideoEncodeBridge"
}
}
@@ -22,8 +22,11 @@ data class CatalogItem(
val extra: Map<String, String> = emptyMap(),
val mature: Boolean = false,
val subscription: Boolean = false,
val includedWith: String = "",
) {
val accessible: Boolean get() = owned || subscription
/** Licensed now, or a supported package the account's subscription can install. */
val installable: Boolean get() = accessible || includedWith.isNotBlank()
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
val key: String get() = "${store.id}:$id"
}
@@ -86,6 +89,18 @@ internal fun retainCatalogOrder(items: List<CatalogItem>, previous: List<Catalog
return previous.map { byKey.getValue(it.key) }
}
/** A detail page opens for search results too; the library wins when it already knows the product. */
fun detailCatalogItem(
store: Store,
id: String,
library: List<CatalogItem>,
found: List<CatalogItem>,
shelves: StoreShelves?,
): CatalogItem? =
library.firstOrNull { it.store == store && it.id == id }
?: found.firstOrNull { it.store == store && it.id == id }
?: shelves?.all?.firstOrNull { it.store == store && it.id == id }
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
if (old.isNullOrEmpty()) return new
val before = old.associateBy { it.key }
@@ -93,6 +108,19 @@ fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogIte
return if (merged.size == old.size && merged.indices.all { merged[it] === old[it] }) old else merged
}
/**
* [patched] over [old] by key, the way a store that fills its cards in one at a time needs it: a card
* already on screen keeps its very object and its place, one the grid does not have yet is added where
* its title belongs. Unlike [mergeItems] the rest of [old] is never dropped.
*/
fun patchItems(old: List<CatalogItem>?, patched: List<CatalogItem>): List<CatalogItem> {
if (old.isNullOrEmpty()) return patched
val byKey = patched.associateBy { it.key }
val kept = old.map { byKey[it.key] ?: it }
val added = patched.filter { n -> old.none { it.key == n.key } }
return if (added.isEmpty()) kept else (kept + added).sortedBy { it.title.lowercase() }
}
class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) {
val key: String get() = "${sidecar.store.id}:${sidecar.id}"
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.stores
import android.content.Context
import com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONArray
import java.util.Locale
/**
* Language for a third-party store install ([Store.GOG], [Store.EPIC]): the app/system locale by
* default, or the code the user picked in the install dialog / store settings.
*
* GOG depots and Epic install tags use overlapping but not identical codes (`en` vs `en-US`,
* `nb` vs `no`). Matching is on the language part plus a few aliases, so a Polish phone selects
* `pl-PL` depots without the user having to know the store's spelling.
*/
object StoreLanguage {
const val EXTRA = "installLanguage"
/** Codes the settings list and Epic's tag table share; GOG often uses the same with a region. */
val KNOWN: List<String> = listOf(
"en", "pl", "de", "fr", "es", "es-419", "pt", "pt-BR", "it", "ru", "uk", "cs", "hu",
"nl", "sv", "da", "fi", "nb", "ro", "bg", "el", "tr", "ar", "th", "vi", "ja", "ko",
"zh-Hans", "zh-Hant",
)
private val ALIASES = mapOf(
"english" to "en", "polish" to "pl", "german" to "de", "french" to "fr", "spanish" to "es",
"italian" to "it", "russian" to "ru", "ukrainian" to "uk", "czech" to "cs", "hungarian" to "hu",
"dutch" to "nl", "swedish" to "sv", "danish" to "da", "finnish" to "fi", "norwegian" to "nb",
"no" to "nb", "romanian" to "ro", "bulgarian" to "bg", "greek" to "el", "turkish" to "tr",
"arabic" to "ar", "thai" to "th", "vietnamese" to "vi", "japanese" to "ja", "korean" to "ko",
"schinese" to "zh-hans", "tchinese" to "zh-hant", "chinese" to "zh-hans",
"brazilian" to "pt-br", "portuguese" to "pt",
)
fun selectable(store: Store): Boolean = store == Store.GOG || store == Store.EPIC
/** Tag actually sent to the store: settings override, else the app/system locale. */
@JvmStatic
fun preferredCode(context: Context): String {
val chosen = SessionPrefs.storesInstallLanguage(context)
if (chosen.isNotEmpty()) return chosen
return tagFor(AppLanguage.effective(context))
}
@JvmStatic
fun preferredCode(): String = tagFor(Locale.getDefault())
fun tagFor(locale: Locale): String {
val language = locale.language.ifEmpty { "en" }
val country = locale.country
val script = locale.script
return when {
language == "zh" && (script == "Hant" || country == "TW" || country == "HK" || country == "MO") -> "zh-Hant"
language == "zh" -> "zh-Hans"
language == "pt" && (country.isEmpty() || country == "BR") -> "pt-BR"
language == "es" && country.isNotEmpty() && country != "ES" -> "es-419"
country.isNotEmpty() -> "$language-$country"
else -> language
}
}
fun localeFor(code: String?, context: Context): Locale {
val tag = code?.takeIf { it.isNotBlank() } ?: preferredCode(context)
return Locale.forLanguageTag(tag.replace('_', '-'))
}
fun fallbackList(store: Store): List<String> = if (selectable(store)) KNOWN else emptyList()
/**
* One code from [available]. An exact normalized code wins (`pl-PL` over `pl`). A base-language
* match is used only when that exact code is not offered, then English, then the first code.
*/
@JvmStatic
fun pick(available: List<String>, preferred: String): String {
if (available.isEmpty()) return preferred
val want = primary(preferred) ?: return available.first()
return available.firstOrNull { primary(it) == want }
?: available.firstOrNull { depotAccepted(arrayOf(it), preferred) }
?: available.firstOrNull { depotAccepted(arrayOf(it), "en") }
?: available.first()
}
/**
* True when a GOG depot whose `languages` array is [listed] overlaps [chosen], including a
* base-language match (`pl` accepts `pl-PL`). Shared depots (`*`, empty, missing) always match.
* Install selection uses [depotMatchesResolved] after [pick] has chosen a single code.
*/
@JvmStatic
fun depotAccepted(listed: Array<String>?, chosen: String): Boolean {
if (listed.isNullOrEmpty()) return true
val want = tokens(chosen)
return listed.any { have ->
val parts = tokens(have)
parts.contains("*") || parts.any { it in want }
}
}
/**
* True when [listed] is shared, or contains the one code [resolved] already selected.
* `pl-PL` does not also keep a `pl` depot.
*/
@JvmStatic
fun depotMatchesResolved(listed: Array<String>?, resolved: String): Boolean {
if (listed.isNullOrEmpty()) return true
if (listed.any { it.trim() == "*" }) return true
val want = primary(resolved) ?: return false
return listed.any { primary(it) == want }
}
/** Normalized code without collapsing to the base language. `pl-PL` stays `pl-pl`. */
internal fun primary(code: String): String? {
val raw = code.trim().lowercase().replace('_', '-')
if (raw.isEmpty()) return null
if (raw == "*") return "*"
return ALIASES[raw] ?: raw
}
@JvmStatic
fun codesFromDepots(depots: JSONArray?): List<String> {
if (depots == null) return emptyList()
val seen = LinkedHashSet<String>()
for (i in 0 until depots.length()) {
val langs = depots.optJSONObject(i)?.optJSONArray("languages") ?: continue
for (j in 0 until langs.length()) {
val raw = langs.optString(j, "").trim()
if (raw.isEmpty() || raw == "*") continue
val key = raw.lowercase()
if (seen.none { it.lowercase() == key }) seen.add(raw)
}
}
return seen.toList()
}
fun displayName(code: String): String {
if (code.isEmpty()) return ""
val locale = Locale.forLanguageTag(code.replace('_', '-'))
AppLanguage.supportedTag(locale)?.let { return AppLanguage.nativeName(it) }
val name = locale.getDisplayName(locale)
return name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
}
internal fun tokens(code: String): Set<String> {
val raw = code.trim().lowercase().replace('_', '-')
if (raw.isEmpty()) return emptySet()
if (raw == "*") return setOf("*")
val mapped = ALIASES[raw] ?: raw
val lang = mapped.substringBefore('-').let { ALIASES[it] ?: it }
val out = mutableSetOf(mapped, lang)
if (mapped.startsWith("zh-hant") || mapped == "zh-tw" || mapped == "zh-hk") {
out.add("zh-hant"); out.add("zh")
}
if (mapped.startsWith("zh-hans") || mapped == "zh-cn") {
out.add("zh-hans"); out.add("zh")
}
if (mapped == "pt-br" || mapped == "br") out.add("pt-br")
return out
}
}
@@ -25,7 +25,7 @@ internal object StoreNet {
headers.forEach { (k, v) -> setRequestProperty(k, v) }
}
val code = conn.responseCode
if (code !in 200..299) { Log.w(TAG, "GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
if (code !in 200..299) { StoresState.logLine("GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
} catch (e: Exception) {
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
@@ -99,7 +99,10 @@ object StoreSizes {
val json = EpicApiClient.getManifestApiJson(token, ns, catalogItemId, item.id) ?: return 0L
val bytes = EpicDownloadManager.downloadManifest(json, EpicDownloadManager.parseCdnUrls(json)) ?: return 0L
val manifest = EpicDownloadManager.parseManifest(bytes) ?: return 0L
EpicDownloadManager.resolveInstallFiles(manifest, EpicInstallTags.tagsForDevice()).sumOf { it.fileSize() }
EpicDownloadManager.resolveInstallFiles(
manifest,
EpicInstallTags.tagsFor(StoreLanguage.localeFor(null, context)),
).sumOf { it.fileSize() }
}
Store.AMAZON -> {
val token = AmazonCredentialStore.getValidAccessToken(context) ?: return 0L
@@ -128,6 +128,10 @@ object StoresState {
fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id }
fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem(
store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store],
)
fun open(context: Context, store: Store, force: Boolean = false) {
val backend = backends[store] ?: return
val app = context.applicationContext
@@ -198,32 +202,35 @@ object StoresState {
askForNotifications(context)
if (isUnfinished(item)) { install(context, item); return }
val targets = StoreInstallRoot.targets(context)
if (targets.size > 1) pendingInstall = item else install(context, item, targets.first().root)
if (targets.size > 1 || StoreLanguage.selectable(item.store)) pendingInstall = item
else install(context, item, targets.first().root)
}
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
fun install(context: Context, item: CatalogItem, root: java.io.File? = null, language: String? = null) {
val app = context.applicationContext
val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, root ?: StoreInstallRoot.installRoot(app))
val target = folder.parentFile!!.parentFile!!
val check = StoreInstallRoot.checkStorage(app, item, target)
val code = language?.takeIf { it.isNotBlank() } ?: StoreLanguage.preferredCode(app)
val tagged = if (StoreLanguage.selectable(item.store)) item.copy(extra = item.extra + (StoreLanguage.EXTRA to code)) else item
val check = StoreInstallRoot.checkStorage(app, tagged, target)
if (!check.enough) {
val errorMsg = check.errorMessage ?: app.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(check.requiredBytes), formatBytes(check.availableBytes))
val entry = com.droiddeck.launcher.stores.download.DownloadEntry(
item.store,
item.id,
item.title,
cover = item.imageUrl,
bytesTotal = item.sizeBytes,
tagged.store,
tagged.id,
tagged.title,
cover = tagged.imageUrl,
bytesTotal = tagged.sizeBytes,
location = StoreInstallRoot.labelFor(app, folder)
)
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, tagged, target)
com.droiddeck.launcher.stores.download.DownloadQueue.recordFailed(app, entry, errorMsg) {
install(app, item, target)
install(app, tagged, target, code)
}
return
}
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
backends[item.store]?.install(app, item, target) ?: logLine("${item.store.label}: this build cannot install yet")
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, tagged, target)
backends[tagged.store]?.install(app, tagged, target) ?: logLine("${tagged.store.label}: this build cannot install yet")
}
val uninstalling = mutableStateMapOf<String, Float>()
@@ -8,17 +8,20 @@ object EpicInstallTags {
@Volatile private var last: Answer? = null
private fun current(): Answer? {
val locale = Locale.getDefault()
private fun answer(locale: Locale): Answer? {
last?.takeIf { it.locale == locale }?.let { return it }
val answer = EpicNative.call("locale", JSONObject().put("language", locale.language).put("region", locale.country)) ?: return null
val list = answer.optJSONArray("tags")
val call = EpicNative.call("locale", JSONObject().put("language", locale.language).put("region", locale.country)) ?: return null
val list = call.optJSONArray("tags")
val tags = List(list?.length() ?: 0) { list!!.optString(it) }.filter { it.isNotEmpty() }
return Answer(locale, tags, answer.optString("launchLocale", "en").ifEmpty { "en" }).also { last = it }
return Answer(locale, tags, call.optString("launchLocale", "en").ifEmpty { "en" }).also { last = it }
}
@JvmStatic
fun tagsForDevice(): List<String> = current()?.tags ?: emptyList()
fun tagsFor(locale: Locale): List<String> = answer(locale)?.tags ?: emptyList()
fun launchLocale(): String = current()?.launchLocale ?: "en"
fun launchLocale(locale: Locale): String = answer(locale)?.launchLocale ?: "en"
@JvmStatic
fun tagsForDevice(): List<String> = tagsFor(Locale.getDefault())
fun launchLocale(): String = launchLocale(Locale.getDefault())
}
@@ -9,6 +9,7 @@ import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.StoreDownloadTier
@@ -29,6 +30,8 @@ internal class EpicInstaller(
private fun scratch(): File = StoreInstallRoot.scratchDir(app, Store.EPIC, item.id)
private fun installLocale() = StoreLanguage.localeFor(item.extra[StoreLanguage.EXTRA], app)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
val token = EpicCredentialStore.getValidAccessToken(app) ?: throw IllegalStateException("Not signed in to Epic Games")
@@ -47,7 +50,7 @@ internal class EpicInstaller(
.put("gameDir", folder.absolutePath)
.put("scratchDir", if (StoreInstallRoot.isRemovable(app, folder)) scratch().absolutePath else "")
.put("storageLabel", StoreInstallRoot.labelFor(app, folder))
.put("tags", JSONArray(EpicInstallTags.tagsForDevice()))
.put("tags", JSONArray(EpicInstallTags.tagsFor(installLocale())))
.put("maxWorkers", tier.networkWindow.coerceIn(1, 128))
.put("processWorkers", tier.processWorkers)
.put("caBundle", "")
@@ -111,7 +114,7 @@ internal class EpicInstaller(
val argsAnswer = EpicNative.call(
"launch.args",
JSONObject().put("displayName", creds?.displayName.orEmpty()).put("accountId", creds?.accountId.orEmpty())
.put("namespace", namespace).put("locale", EpicInstallTags.launchLocale()).put("appName", item.id)
.put("namespace", namespace).put("locale", EpicInstallTags.launchLocale(installLocale())).put("appName", item.id)
.put("deploymentId", deployment).put("additional", additional),
)
val argList = argsAnswer?.optJSONArray("args")
@@ -122,6 +125,7 @@ internal class EpicInstaller(
extra["namespace"] = namespace
extra["catalogItemId"] = catalogItemId
if (deployment.isNotEmpty()) extra["deploymentId"] = deployment
extra[StoreLanguage.EXTRA] = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app)
extra[WinCompSources.EPIC_PREREQ_PATH] = result.optString("prereqPath")
extra[WinCompSources.EPIC_PREREQ_NAME] = result.optString("prereqName")
if (result.optBoolean("prereqRead", false)) extra[WinCompSources.LIST_READ] = "1"
@@ -9,6 +9,7 @@ import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
@@ -37,7 +38,16 @@ object GogBackend : StoreBackend {
if (cached.isNotEmpty()) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached)
if (!quiet) StoresState.status[store] = app.getString(com.droiddeck.launcher.R.string.stores_sync_library)
}
val result = GogLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }
val result = GogLibrary.sync(app, force, onStatus = { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }, onList = { list ->
// Mapped here, off the main thread: the listing paints the tab before any product is asked about.
val items = list.map { GogLibrary.toCatalogItem(app, it) }
// An empty listing is not news: posting it would drop the cards already on screen.
if (items.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], items) }
}, onItems = { changed ->
// Only the products that came back are touched: a card already on screen keeps its object, and a product the grid has not seen yet is added where its title belongs.
val items = changed.map { GogLibrary.toCatalogItem(app, it) }
StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.patchItems(StoresState.library[store], items) }
})
StoresState.post {
StoresState.status.remove(store)
when (result) {
@@ -82,6 +92,7 @@ object GogBackend : StoreBackend {
handle.stage(DownloadStage.MANIFEST)
StoreInstalls.begin(folder, Store.GOG, game.gameId, game.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl)
var downloading = false
val language = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app)
val result = GogDownloadManager.install(app, game, folder, object : GogDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {
when {
@@ -99,15 +110,17 @@ object GogBackend : StoreBackend {
handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL })
handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install")
}
}, cancelled) ?: return null
}, cancelled, language) ?: return null
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL, app.getString(com.droiddeck.launcher.R.string.stores_registering))
val installedLanguage = result.language?.takeIf { it.isNotEmpty() } ?: language
val sidecar = StoreGameSidecar(
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap())
+ mapOf(StoreLanguage.EXTRA to installedLanguage),
)
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.stores.gog;
import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLanguage;
import com.droiddeck.launcher.stores.StoreLog;
import com.droiddeck.launcher.stores.download.StoreDownloadTier;
@@ -57,6 +58,8 @@ public final class GogDownloadManager {
public final long bytes;
public List<String> dependencies = new ArrayList<>();
public boolean dependenciesRead = false;
/** Language actually written, after the manifest's depot list resolved the request. Null for builds that have no language depots. */
public String language;
Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
}
@@ -67,17 +70,23 @@ public final class GogDownloadManager {
private GogDownloadManager() {}
public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled) throws InstallException {
return install(ctx, game, installPath, cb, cancelled, null);
}
public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled, String language) throws InstallException {
cb.onProgress("Checking sign-in…", 0);
String token = GogAuth.validToken(ctx);
if (token == null) throw new InstallException("Not signed in to GOG");
if (cancelled.get()) return null;
String lang = (language == null || language.isEmpty()) ? StoreLanguage.preferredCode(ctx) : language;
cb.onLog("gog: installing language " + lang);
cb.onProgress("Fetching builds…", 2);
String buildsUrl = "https://content-system.gog.com/products/" + game.gameId + "/os/windows/builds?generation=2";
String buildsJson = httpGet(buildsUrl, null);
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
if (buildsJson != null) {
Gen2Outcome out = runGen2(ctx, game, token, buildsJson, installPath, cb, cancelled);
Gen2Outcome out = runGen2(ctx, game, token, buildsJson, installPath, cb, cancelled, lang);
if (out.result != null || cancelled.get()) return out.result;
if (out.hardStop) throw new InstallException(out.error);
cb.onLog("gen2 unavailable: " + out.error);
@@ -100,7 +109,7 @@ public final class GogDownloadManager {
Result result; String error; boolean hardStop;
}
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled) {
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) {
Gen2Outcome out = new Gen2Outcome();
try {
cb.onProgress("Fetching manifest…", 5);
@@ -127,12 +136,21 @@ public final class GogDownloadManager {
cb.onProgress("Reading depot manifests…", 10);
List<DepotFile> files = new ArrayList<>();
List<String> depotJsons = new ArrayList<>();
JSONArray owned = new JSONArray();
List<JSONObject> ownedDepots = new ArrayList<>();
for (int i = 0; i < depots.length(); i++) {
if (cancelled.get()) return out;
JSONObject depot = depots.getJSONObject(i);
String depotPid = depot.optString("productId", "");
if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
if (!languageCompatible(depot)) continue;
owned.put(depot);
ownedDepots.add(depot);
}
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language);
cb.onLog("gog: language " + resolved);
for (int i = 0; i < ownedDepots.size(); i++) {
if (cancelled.get()) return out;
JSONObject depot = ownedDepots.get(i);
if (!depotSelected(depot, resolved)) continue;
String manifestHash = depot.optString("manifest");
if (manifestHash == null || manifestHash.isEmpty()) continue;
byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
@@ -232,6 +250,7 @@ public final class GogDownloadManager {
out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
out.result.dependencies = dependencies;
out.result.dependenciesRead = true;
out.result.language = resolved;
return out;
} catch (Exception e) {
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
@@ -270,11 +289,31 @@ public final class GogDownloadManager {
}
}
private static boolean languageCompatible(JSONObject depot) {
private static boolean depotSelected(JSONObject depot, String resolved) {
JSONArray languages = depot.optJSONArray("languages");
if (languages == null || languages.length() == 0) return true;
String s = languages.toString();
return s.contains("*") || s.contains("en-US") || s.contains("\"en\"") || s.contains("english");
String[] listed = new String[languages.length()];
for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, "");
return StoreLanguage.depotMatchesResolved(listed, resolved);
}
/** Language codes listed on this product's Windows gen2 depots, excluding `*`. */
public static List<String> listLanguages(Context ctx, String gameId) {
try {
String token = GogAuth.validToken(ctx);
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
String buildsJson = httpGet(buildsUrl, null);
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
if (buildsJson == null) return Collections.emptyList();
Callback silent = new Callback() {
@Override public void onProgress(String message, int pct) {}
};
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent);
if (gm == null) return Collections.emptyList();
return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots"));
} catch (Exception e) {
return Collections.emptyList();
}
}
@@ -1037,9 +1076,10 @@ public final class GogDownloadManager {
JSONArray depots = new JSONObject(manifestStr).optJSONArray("depots");
if (depots == null) return -1;
long total = 0;
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode());
for (int i = 0; i < depots.length(); i++) {
JSONObject depot = depots.getJSONObject(i);
if (languageCompatible(depot)) total += depot.optLong("size", 0);
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
}
return total > 0 ? total : -1;
} catch (Exception e) {
@@ -7,6 +7,7 @@ import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.cleanStoreText
import org.json.JSONArray
import org.json.JSONObject
@@ -20,6 +21,14 @@ object GogLibrary {
private const val LAST_SYNC_KEY = "library_synced_at"
private const val SKIPPED_KEY = "library_skipped"
private const val THROTTLE_MS = 6L * 60L * 60L * 1000L
// mediaType=1 leaves the movies and soundtracks out, hiddenFlag=0 the hidden ones.
private const val LIST_URL = "https://embed.gog.com/account/getFilteredProducts?mediaType=1&hiddenFlag=0&sortBy=title"
private const val LIST_MAX_PAGES = 100
// The listing's image is a bare hash, and the CDN serves it only through Galaxy's own formatters: the
// vertical cover is the one card art it carries. The wide background only the per-game fetch knows.
private const val COVER_SUFFIX = "_glx_vertical_cover.webp"
// The older account endpoint: owned ids only. The fallback for a listing that cannot be read.
private const val OWNED_URL = "https://embed.gog.com/user/data/games"
private val syncing = AtomicBoolean(false)
@@ -45,42 +54,44 @@ object GogLibrary {
fun sizeOf(context: Context, gameId: String): Long = GogPrefs.get(context).getLong("size_$gameId", 0L)
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult {
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}, onList: (List<GogGame>) -> Unit = {}, onItems: (List<GogGame>) -> Unit = {}): SyncResult {
if (!syncing.compareAndSet(false, true)) return SyncResult.Busy
try {
val app = context.applicationContext
val p = GogPrefs.get(app)
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
onStatus(app.getString(R.string.stores_sync_game_list))
val gamesJson = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token, userAgent = GogAuth.GALAXY_UA)
val listing = ownedListing(token)
// The listing is the only source of titles: without it the owned ids still sync, just without the early paint.
val listed: List<GogGame> = listing?.games
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
val ids = ArrayList<String>()
val parsed = runCatching {
val owned = JSONObject(gamesJson).optJSONArray("owned")
if (owned != null) for (i in 0 until owned.length()) {
val id = owned.optLong(i).toString()
if (id != "1801418160" && id != "0") ids.add(id)
}
}.isSuccess
if (!parsed) return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreadable))
val ownedSet = ids.toHashSet()
val named = listing != null
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
// A page that failed leaves games unnamed: they are still owned, so their cards and cached details stay.
val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }
val cachedList = cached(app)
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
val heavy = force || cachedList.isEmpty() || stale
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
// A product the last sync found uninstallable stays out of the list while it is re-checked.
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
if (!heavy) skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
val idsToFetch = if (heavy) ids else ids.filter { it !in cachedIds && it !in skipped }
val fetchSet = idsToFetch.toHashSet()
skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped }
val merged = LinkedHashMap<String, GogGame>()
for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in fetchSet) merged[g.gameId] = g
// The cache's own entry stays even when the game is re-fetched: its details beat the listing's.
for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in skipped) merged[g.gameId] = g
// A game the sync has not reached keeps the listing's entry, so its card is there from the start.
for (g in listed) if (g.gameId !in merged && g.gameId !in skipped) merged[g.gameId] = g
// Only products that were asked about are cached: a bare listing entry is asked about again.
val detailed = HashSet(cachedIds)
if (named) onList(sorted(merged))
if (idsToFetch.isEmpty()) {
val list = merged.values.toList()
skipped.retainAll(ownedSet)
p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply()
saveCache(p, list)
return SyncResult.Ok(list.sortedBy { it.title.lowercase() }, 0)
saveCache(p, list.filter { it.gameId in detailed })
return SyncResult.Ok(sorted(merged), 0)
}
onStatus(app.resources.getQuantityString(R.plurals.stores_sync_games, idsToFetch.size, idsToFetch.size))
val pool = Executors.newFixedThreadPool(5)
@@ -89,16 +100,24 @@ object GogLibrary {
var fetched = 0
for ((idx, f) in futures.withIndex()) {
val g = runCatching { f.get() }.getOrNull()
if (g != null) { merged[g.gameId] = g; fetched++ }
if (g != null) {
// The cover the card already shows stays, so the wide art lands over it instead of over a gap.
val filled = g.withCover(merged[g.gameId]?.verticalCover)
merged[g.gameId] = filled; detailed.add(g.gameId); fetched++; skipped.remove(g.gameId); onItems(listOf(filled))
}
if ((idx + 1) % 5 == 0) onStatus(app.getString(R.string.stores_sync_progress, idx + 1, futures.size))
// Saved as it goes: a sync the user closes the app on still leaves a library to paint next launch.
if ((idx + 1) % 20 == 0) saveCache(p, merged.values.filter { it.gameId in detailed })
}
// A product this round found uninstallable leaves when the round is over, not in the middle of it.
for (id in skipped) merged.remove(id)
val finalList = merged.values.toList()
saveCache(p, finalList)
saveCache(p, finalList.filter { it.gameId in detailed })
skipped.retainAll(ownedSet)
p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply()
if (heavy) p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
Log.i(TAG, "sync: owned=${ids.size} fetched=$fetched cached=${finalList.size} heavy=$heavy")
return SyncResult.Ok(finalList.sortedBy { it.title.lowercase() }, fetched)
Log.i(TAG, "sync: owned=${ownedSet.size} fetched=$fetched cached=${finalList.size} heavy=$heavy")
return SyncResult.Ok(sorted(merged), fetched)
} catch (e: Exception) {
Log.w(TAG, "sync failed: ${e.message}")
return SyncResult.Failed(e.message ?: context.getString(R.string.stores_sync_failed))
@@ -107,6 +126,68 @@ object GogLibrary {
}
}
private fun ownedListing(token: String): Listing? {
val all = ArrayList<GogGame>()
val owned = HashSet<String>()
var page = 1
var pages = 1
while (page <= pages && page <= LIST_MAX_PAGES) {
// A page that cannot be read ends the listing; the pages already read still name most of the library.
val listed = StoreNet.get("$LIST_URL&page=$page", bearer = token, userAgent = GogAuth.GALAXY_UA)?.let { parseListing(it) }
if (listed == null) {
StoresState.logLine("GOG: listing page $page of $pages unreadable")
// The unread pages hold games too: without their ids those games would leave the library and the cache.
owned.addAll(ownedIds(token) ?: return null)
break
}
all.addAll(listed.games)
owned.addAll(listed.games.map { it.gameId })
pages = listed.totalPages
page++
}
// Not one page read: the caller falls back to the owned ids.
return all.ifEmpty { null }?.let { Listing(it, owned) }
}
private fun ownedIds(token: String): List<String>? {
val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null
val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null
val ids = ArrayList<String>(owned.length())
for (i in 0 until owned.length()) {
val id = owned.optLong(i).toString()
if (id != "1801418160" && id != "0") ids.add(id)
}
return ids
}
fun parseListing(json: String): ListingPage? = runCatching {
val root = JSONObject(json)
val arr = root.optJSONArray("products") ?: return@runCatching null
val games = ArrayList<GogGame>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
// 1801418160 is GOG's own "Galaxy" entry, in every library.
val id = o.optLong("id", 0L).toString()
if (id == "0" || id == "1801418160") continue
if (o.optBoolean("isMovie", false) || !o.optBoolean("isGame", true)) continue
val title = o.optString("title", "").trim()
if (title.isEmpty()) continue
val worksOn = o.optJSONObject("worksOn")
if (worksOn != null && !worksOn.optBoolean("Windows", true)) continue
val released = o.optJSONObject("releaseDate")?.optString("date", "").orEmpty().substringBefore(' ')
val image = o.optString("image", "").ifBlank { null }
games.add(GogGame(id, title, "", "", "", o.optString("category", ""), 1,
image?.let { GogStoreCatalog.absolutize(it + COVER_SUFFIX) }, released, ""))
}
ListingPage(games, maxOf(1, root.optInt("totalPages", 1)))
}.getOrNull()
class ListingPage(val games: List<GogGame>, val totalPages: Int)
private class Listing(val games: List<GogGame>, val owned: Set<String>)
private fun sorted(merged: Map<String, GogGame>): List<GogGame> = merged.values.sortedBy { it.title.lowercase() }
private fun fetchGame(prefs: SharedPreferences, id: String, token: String, skipped: MutableSet<String>): GogGame? {
fun skip(): GogGame? { skipped.add(id); return null }
try {
@@ -198,6 +279,10 @@ object GogLibrary {
}
}
private fun GogGame.withCover(cover: String?): GogGame =
if (cover.isNullOrBlank() || cover == verticalCover) this
else GogGame(gameId, title, imageUrl, description, developer, category, generation, cover, releaseDate, publisher)
fun toCatalogItem(context: Context, g: GogGame): CatalogItem = CatalogItem(
store = Store.GOG, id = g.gameId, title = g.title,
imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null },
@@ -10,6 +10,14 @@ import java.time.Instant
internal object XboxCatalog {
private val productId = Regex("[A-Z0-9]{12}")
private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
/** Plans that include PC games; the retired console-only plan must not offer a Windows install. */
private val pcPlans = mapOf(
"CFQ7TTC0KGQ8" to "PC Game Pass",
"CFQ7TTC0KHS0" to "Game Pass Ultimate",
"CFQ7TTC0P85B" to "Xbox Game Pass Premium",
"CFQ7TTC0K5DJ" to "Xbox Game Pass Essential",
"CFQ7TTC10QFD" to "Xbox Game Pass (Starter Edition)",
)
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map<String, String> {
require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
@@ -53,7 +61,7 @@ internal object XboxCatalog {
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
if (parent == null) return if (id in plans) null else "owned"
if (parent in blocked) return null
if (parent in plans) return if (activePlan(parent)) "gamepass" else null
if (parent in plans) return if (parent in pcPlans && activePlan(parent)) "gamepass" else null
return kind(parent, visited + id)
}
val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
@@ -110,9 +118,12 @@ internal object XboxCatalog {
}
}
val kind = access[id]
val includedWith = objects(localized.optJSONObject("EligibilityProperties")?.optJSONArray("Affirmations"))
.mapNotNull { pcPlans[it.optString("AffirmationProductId")] }
.firstOrNull().orEmpty()
return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"),
tallImageUrl = image("TitledPoster", "Poster", "BoxArt"),
owned = kind == "owned", subscription = kind == "gamepass",
owned = kind == "owned", subscription = kind == "gamepass", includedWith = includedWith,
storeUrl = "https://www.xbox.com/games/store/$id",
publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"),
description = localized.optString("ProductDescription"),
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.stores.xbox
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
@@ -14,7 +15,8 @@ internal object Xodus {
const val DIR = "/usr/local/lib/droiddeck-xbox"
fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable {
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null,
private val networkLink: LinuxNetworkLinkComponent) : Closeable {
@Synchronized fun send(frame: JSONObject) {
process.outputStream.write((frame.toString() + "\n").toByteArray())
process.outputStream.flush()
@@ -34,6 +36,7 @@ internal object Xodus {
}
}
} finally {
networkLink.stop()
runtimeDirectory?.let { directory ->
File(directory, "xodus.sock").delete()
directory.delete()
@@ -69,8 +72,12 @@ internal object Xodus {
val pack = stage(app)
val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}"
check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." }
val networkLink = LinuxNetworkLinkComponent(app, LinuxRuntime.rootDir(app))
val credentials = XboxCredentials(app, credentialGeneration)
try {
networkLink.attach(app)
networkLink.publish()
networkLink.start()
val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() }
val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf(
"/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off",
@@ -92,8 +99,12 @@ internal object Xodus {
}
val process = builder.start()
credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest())
return Child(process, credentials, runtimeDirectory, launchDirectory)
} catch (e: Exception) { credentials.close(); throw e }
return Child(process, credentials, runtimeDirectory, launchDirectory, networkLink)
} catch (e: Exception) {
runCatching { networkLink.stop() }
credentials.close()
throw e
}
}
fun run(app: Context, args: List<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
@@ -40,6 +40,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import android.content.Context
@@ -502,7 +508,7 @@ private fun ArtChooserDialog(
sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER
}
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
LaunchedEffect(options != null) { focusWithinFrames({ false }) { first } }
val apply: (AddedGameArt.Option) -> Unit = { option ->
scope.launch {
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
@@ -624,14 +630,23 @@ private fun SgdbSearchDialog(
}
}
LaunchedEffect(Unit) { search() }
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) {
val firstItem = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val leaveField = { focusManager.clearFocus(); keyboardController?.hide() }
val keyboard = LocalInputModeManager.current.inputMode == InputMode.Keyboard
LaunchedEffect(picked?.id, matches?.size, images.size, busy) {
val ready = if (picked != null) images.isNotEmpty() else !matches.isNullOrEmpty()
if (keyboard && !busy && ready) focusSoon { firstItem }
}
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp, modifier = Modifier.pointerInput(Unit) { detectTapGestures { leaveField() } }) {
DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = {
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
})
androidx.compose.material3.OutlinedTextField(
query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.art_sgdb_search_label)) },
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { leaveField(); search() }),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
)
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp
@@ -643,24 +658,24 @@ private fun SgdbSearchDialog(
val chosenGame = picked
val list = matches.orEmpty()
if (chosenGame == null) {
for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } }
list.forEachIndexed { i, match -> key(match.id) { PickerRow(null, match.name, null, focus = if (i == 0) firstItem else null) { leaveField(); showImages(match) } } }
if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
} else {
Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (option in images) key(option.full) {
images.forEachIndexed { i, option -> key(option.full) {
ArtOption(
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null,
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = if (i == 0) firstItem else null,
description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName",
) { onPick(option) }
}
} }
}
if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null }
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { leaveField(); picked = null }
}
if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
}
@@ -69,6 +69,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInputModeManager
@@ -558,13 +559,37 @@ private fun repoInfo(post: SteamDeckRepo.Post): String =
listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "")
.filter { it.isNotEmpty() }.joinToString(" · ")
private const val AFTER_OPEN = 3
private const val AFTER_INSTALL = 1
private const val AFTER_USE = 2
/** Install and Use are different buttons in turn, and the button is disabled while installing; it takes focus back for the controller. */
@Composable
private fun RepoAction(installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit) {
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = modifier, onClick = {})
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = modifier, onClick = onUse)
private fun RepoAction(
installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit,
takeFocus: Boolean = false,
) {
val requester = remember { FocusRequester() }
val focused = remember { booleanArrayOf(false) }
var pending by remember { mutableIntStateOf(if (takeFocus) AFTER_OPEN else 0) }
val inputMode = LocalInputModeManager.current
LaunchedEffect(installed, inUse, busy, percent != null, pending) {
if (pending == 0) return@LaunchedEffect
if (inputMode.inputMode != InputMode.Keyboard) { pending = 0; return@LaunchedEffect }
focusWithinFrames({ focused[0] }) { requester }
when (pending) {
AFTER_OPEN -> pending = 0
AFTER_INSTALL -> if (installed && focused[0]) pending = 0
AFTER_USE -> if (inUse && focused[0]) pending = 0
else -> Unit
}
}
val m = modifier.focusRequester(requester).onFocusChanged { focused[0] = it.hasFocus || it.isFocused }
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = m, onClick = {})
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = m, onClick = { pending = AFTER_USE; onUse() })
else SecondaryButton(
if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install),
enabled = !busy, compact = true, modifier = modifier, onClick = onInstall,
enabled = !busy, compact = true, modifier = m, onClick = { pending = AFTER_INSTALL; onInstall() },
)
}
@@ -576,9 +601,6 @@ private fun PreviewDialog(
) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
val action = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { action } }
val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp
AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) {
DialogTitleRow(post.title, close)
@@ -586,7 +608,7 @@ private fun PreviewDialog(
MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f))
}
Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse, takeFocus = true)
}
}
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -73,6 +74,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -381,6 +384,7 @@ fun ComponentsPage(
var stackAt by remember { mutableStateOf(Offset.Zero) }
var pageAt by remember { mutableStateOf(Offset.Zero) }
val landed = remember { mutableStateMapOf<String, Int>() }
val nightlyPicked = remember { mutableStateMapOf<String, Boolean>() }
val rings = remember { mutableStateMapOf<String, Int>() }
fun land(l: String) { landed[l] = (landed[l] ?: 0) + 1; rings[l] = (rings[l] ?: 0) + 1 }
val arrivedSeen = remember { intArrayOf(Hops.arrived) }
@@ -504,7 +508,7 @@ fun ComponentsPage(
val availableTitle: @Composable (String) -> String = { l ->
when (l) {
PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null || it.update != null })
else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size)
else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size)
}
}
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
@@ -581,12 +585,16 @@ fun ComponentsPage(
) { protonActions.onInstall(row.id) }
}
else -> {
val available = availableFor(l, catalog, snapshot)
val available = availableRows(l, catalog, snapshot)
if (available.isEmpty()) EmptyLine(if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new))
for (d in available) GetLine(
d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0),
progress = downloads[d.file], enabled = busy == null,
) { onDownload(d) }
for ((stable, nightly) in available) {
val d = if (nightly != null && nightlyPicked[stable.file] == true) nightly else stable
GetLine(
d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0),
progress = downloads[d.file], enabled = busy == null,
channel = if (nightly != null) ({ ChannelToggle(d === nightly, enabled = busy == null) { nightlyPicked[stable.file] = d !== nightly } }) else null,
) { onDownload(d) }
}
}
}
}
@@ -922,6 +930,14 @@ private fun availableFor(l: String, catalog: List<CatalogItem>, snapshot: Snapsh
return catalog.filter { it.comp == l && ComponentsManager.safeName(it.file) !in stored }
}
// The rows to show: each stable row carries its paired nightly (if any), and a paired nightly has no row of its own.
private fun availableRows(l: String, catalog: List<CatalogItem>, snapshot: Snapshot?): List<Pair<CatalogItem, CatalogItem?>> {
val available = availableFor(l, catalog, snapshot)
val pairs = ComponentsManager.nightlyPairs(available)
val paired = pairs.values.toSet()
return available.filter { it !in paired }.map { it to pairs[it] }
}
@Composable
private fun LayerTitle(l: String): String = when (l) {
GPU_TAB -> stringResource(R.string.comp_gpu_drivers)
@@ -1417,7 +1433,8 @@ private fun ChoiceLine(
@Composable
private fun GetLine(
name: String, detail: String, progress: Int?, enabled: Boolean,
badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null, onGet: () -> Unit,
badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null,
channel: (@Composable () -> Unit)? = null, onGet: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -1432,12 +1449,43 @@ private fun GetLine(
if (progress != null) ProgressBit(progress)
else {
if (onCancel != null) FocusText(stringResource(R.string.proton_cancel_queued), pal.signal, onClick = onCancel)
channel?.invoke()
SmallButton(action ?: stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet)
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
// Stable | Nightly in the same box as SmallButton: one focus stop, A (or a tap) flips it.
@Composable
private fun ChannelToggle(nightly: Boolean, enabled: Boolean, onFlip: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
val state = stringResource(if (nightly) R.string.comp_channel_nightly else R.string.comp_channel_stable)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
.background(colors.surfaceVariant)
.glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp)
.hoverable(src).toggleable(value = nightly, interactionSource = src, indication = null, enabled = enabled, role = Role.Switch, onValueChange = { onFlip() })
.controllerConfirm(enabled = enabled, onClick = onFlip)
.semantics { stateDescription = state }
.padding(4.dp),
) {
for ((label, on) in listOf(R.string.comp_channel_stable to !nightly, R.string.comp_channel_nightly to nightly)) {
Text(
stringResource(label), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (!enabled) colors.onSurfaceVariant else if (on) (if (hot) pal.onSignal else pal.signal) else colors.onSurfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(7.dp))
.background(if (on && enabled) (if (hot) pal.signal else pal.signal.copy(alpha = 0.16f)) else Color.Transparent)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
}
@Composable
private fun DetailLine(detail: String, badge: String?) {
val colors = MaterialTheme.colorScheme
@@ -17,6 +17,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.DeckControls
@@ -49,13 +50,6 @@ class DeckControlsPanel(
select(if (pad != null) selectedWithPad else selected)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) {
post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) }
}
}
override fun onDetachedFromWindow() {
DeckControls.releaseAll()
super.onDetachedFromWindow()
@@ -85,8 +79,10 @@ class DeckControlsPanel(
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
}
}
val centre = FrameLayout(context).apply {
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER))
val centre = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44)))
}
addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
addView(roundButton("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu, wide = true))
@@ -102,6 +98,17 @@ class DeckControlsPanel(
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
}
tabViews[tab]?.let { selectedView ->
(selectedView.parent?.parent as? HorizontalScrollView)?.post {
val left = selectedView.left
val right = selectedView.right
val scroll = selectedView.parent?.parent as? HorizontalScrollView ?: return@post
when {
left < scroll.scrollX -> scroll.smoothScrollTo(left, 0)
right > scroll.scrollX + scroll.width -> scroll.smoothScrollTo(right - scroll.width, 0)
}
}
}
content.removeAllViews()
topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE
content.addView(
@@ -643,7 +650,7 @@ class DeckControlsPanel(
private const val CLICK_MS = 300L
private var selected = Tab.BOTH
private var selectedWithPad = Tab.GAMEPAD
private var selectedWithPad = Tab.BOTH
private const val CORNER = 20f
private const val CORNER_PILL = 22f
@@ -268,6 +268,21 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA
}
}
/** A skipped sync is not a success: "0 files" used to show for a signed-out store or cloud saves turned off. */
internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.launcher.stores.CloudSaves.Result): String = when (r.result) {
"ok" -> when {
r.conflicts > 0 -> context.getString(R.string.cloud_conflict_files, r.conflicts)
r.files == 0 -> context.getString(R.string.cloud_up_to_date)
else -> context.getString(R.string.cloud_done, r.files)
}
"skipped" -> context.getString(when (r.reason) {
"not-signed-in" -> R.string.cloud_signed_out
"no-cloud-saves" -> R.string.cloud_none
else -> R.string.cloud_not_synced
})
else -> context.getString(R.string.cloud_not_synced)
}
@Composable
private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compose.ui.focus.FocusRequester) {
val store = com.droiddeck.launcher.stores.Store.byId(g.source)?.takeIf { it != com.droiddeck.launcher.stores.Store.AMAZON } ?: return
@@ -290,7 +305,7 @@ private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compo
busy = if (up) "up" else "down"
Thread({
val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id, force) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id, force)
com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, if (r.ok) context.getString(R.string.cloud_done, r.files) else r.reason, android.widget.Toast.LENGTH_SHORT).show() }
com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, cloudMessage(context, r), android.widget.Toast.LENGTH_SHORT).show() }
}, "cloud-manual").start()
}
MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, focusRequester = first,
@@ -302,7 +317,7 @@ private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compo
com.droiddeck.launcher.stores.StoresState.post { enabled = written?.cloud ?: enabled }
}, "cloud-switch").start()
}
val ready = st != null && supported && busy == null
val ready = st != null && supported && enabled && busy == null
val pending = stringResource(R.string.cloud_pending)
val summary = when {
st == null || busy != null -> pending
@@ -132,7 +132,7 @@ class FrontEndState(
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
val showPhantomGate: Boolean = false,
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
val launcherFullscreen: Boolean = true,
val downloadSource: String = DownloadSource.MODE_AUTO,
val downloadMirror: String = "",
@@ -79,6 +79,33 @@ internal object GlassesTab {
const val VR = "vr"
const val HARDWARE = "hardware"
const val TRACKING = "tracking"
const val CONTROLLERS = "controllers"
}
@Composable
internal fun VrControllerGroups(host: MenuHost, track: (String) -> Modifier = { Modifier }) {
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) { com.droiddeck.launcher.input.VrControllers.load(context) }
val c by com.droiddeck.launcher.input.VrControllers.settings.collectAsState()
val emulate = com.droiddeck.launcher.input.VrControllers.Emulate.entries
SettingsGroup(stringResource(R.string.vr_controllers_group)) {
ChoiceRow(host, "vr-emulate", stringResource(R.string.vr_controllers_emulate), stringResource(R.string.vr_controllers_emulate_hint),
emulate.map { it to stringResource(when (it) {
com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_off
com.droiddeck.launcher.input.VrControllers.Emulate.INDEX -> R.string.vr_controllers_index
com.droiddeck.launcher.input.VrControllers.Emulate.TOUCH -> R.string.vr_controllers_touch
com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_vive
}) }, c.emulate, chipModifier = track("vr-emulate")) { com.droiddeck.launcher.input.VrControllers.setEmulate(context, it) }
if (c.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ChoiceRow(host, "vr-sticks",
stringResource(R.string.vr_controllers_sticks), stringResource(R.string.vr_controllers_sticks_hint),
listOf(false to stringResource(R.string.vr_controllers_sticks_game), true to stringResource(R.string.vr_controllers_sticks_hands)),
c.sticksMoveHands, chipModifier = track("vr-sticks")) { com.droiddeck.launcher.input.VrControllers.setSticksMoveHands(context, it) }
SettingsRow(stringResource(R.string.vr_controllers_buttons), stringResource(when (c.emulate) {
com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_buttons_off
com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_buttons_vive
else -> R.string.vr_controllers_buttons_stick
}), hintLines = 6) {}
}
}
@Composable
@@ -362,13 +389,3 @@ private fun GlassesRecenterAction(label: String, tag: String, tint: Int, opacity
}
}
@Composable
internal fun GlassesPlayingHint(model: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(56.dp))
Spacer(Modifier.height(10.dp))
Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.45f), fontSize = 15.sp)
}
}
}
@@ -0,0 +1,85 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
/** Actions for the phone while the picture is on the glasses. Null hides the button. */
internal class GlassesPhoneActions(
val onRecenter: (() -> Unit)?,
val onRecenterJoyCons: (() -> Unit)?,
val onSteamVrMenu: (() -> Unit)?,
val onSwitchSticks: (() -> Unit)?,
val controllers: String?,
val onNextControllers: (() -> Unit)?,
val onKeyboard: () -> Unit,
val onSessionMenu: () -> Unit,
val onStopSteamVr: (() -> Unit)?,
val brightness: Int,
val brightnessMax: Int,
val onBrightness: ((Int) -> Unit)?,
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun GlassesPhonePanel(model: String, status: String?, a: GlassesPhoneActions) {
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.displayCutout).padding(16.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(max = 640.dp)) {
Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(40.dp))
Spacer(Modifier.height(6.dp))
Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.6f), fontSize = 15.sp)
if (status != null) Text(status, color = Color.White.copy(alpha = 0.4f), fontSize = 13.sp, textAlign = TextAlign.Center)
Spacer(Modifier.height(16.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
a.onRecenter?.let { PanelButton(stringResource(R.string.glasses_phone_recenter), "phone-recenter", it) }
a.onRecenterJoyCons?.let { PanelButton(stringResource(R.string.glasses_recenter_joycons_button), "phone-recenter-joycons", it) }
a.onSteamVrMenu?.let { PanelButton(stringResource(R.string.glasses_phone_steamvr_menu), "phone-steamvr-menu", it) }
a.onNextControllers?.let { next -> a.controllers?.let {
PanelButton(stringResource(R.string.glasses_phone_controllers, it), "phone-controllers", next) } }
a.onSwitchSticks?.let { PanelButton(stringResource(R.string.glasses_phone_switch_sticks), "phone-switch-sticks", it) }
PanelButton(stringResource(R.string.glasses_phone_keyboard), "phone-keyboard", a.onKeyboard)
PanelButton(stringResource(R.string.glasses_phone_session_menu), "phone-session-menu", a.onSessionMenu)
a.onStopSteamVr?.let { PanelButton(stringResource(R.string.glasses_phone_stop_steamvr), "phone-stop-steamvr", it) }
}
val setBrightness = a.onBrightness
if (setBrightness != null && a.brightnessMax > 0) {
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PanelButton("−", "phone-brightness-down") { setBrightness((a.brightness - 1).coerceAtLeast(0)) }
Text(stringResource(R.string.glasses_phone_brightness, a.brightness.coerceAtLeast(0), a.brightnessMax),
color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp)
PanelButton("+", "phone-brightness-up") { setBrightness((a.brightness + 1).coerceAtMost(a.brightnessMax)) }
}
}
}
}
}
@Composable
private fun PanelButton(label: String, tag: String, onClick: () -> Unit) =
SecondaryButton(label, compact = true, modifier = Modifier.testTag(tag), onClick = onClick)
@@ -3,6 +3,9 @@ package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -105,7 +108,7 @@ private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) {
}
@Composable
private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
@@ -113,7 +116,7 @@ private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(8.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.clip(shape)
modifier = Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(shape)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal)
@@ -140,6 +143,9 @@ internal fun LogoPositionDialog(
val shown = rememberShown(onBack)
val back = { shown.targetState = false }
var value by remember { mutableStateOf(start ?: LogoPosition.DEFAULT) }
val startPin = remember { value.pin }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusSoon { first } }
var failed by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
val save: (LogoPosition.Value?) -> Unit = { next ->
@@ -172,7 +178,7 @@ internal fun LogoPositionDialog(
)
Text(stringResource(R.string.logo_pos_position), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin) { value = value.copy(pin = pin) }
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin, if (pin == startPin) first else null) { value = value.copy(pin = pin) }
}
ValueSlider(
value.widthPct, LogoPosition.MIN_PCT..LogoPosition.MAX_PCT, step = 1, enabled = true, label = stringResource(R.string.logo_pos_size),
@@ -14,6 +14,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -81,6 +82,8 @@ class ModeSettings(
val picturePosition: String? = null,
val stretch16x9: Boolean? = null,
val steamClientArch: String? = null,
/** Steam only: a game launched from the front end returns there when it closes; null hides the row. */
val returnAfterGame: Boolean? = null,
val steamRepairQueued: Boolean = false,
val mangoapp: Boolean = true,
val steamController: String? = null,
@@ -98,6 +101,7 @@ class ModeSettings(
val wifiDiscoveryBlocked: Boolean = false,
val deckyInstalled: String? = null,
val deckyLatestRelease: DeckyManager.Release? = null,
val deckyBranch: Int = DeckyManager.BRANCH_STABLE,
val deckyChecking: Boolean = false,
val deckyStage: String? = null,
val deckyPercent: Int = -1,
@@ -133,6 +137,7 @@ class ModeSettingsActions(
val onSteamRepair: () -> Unit = {},
val onSteamClientArch: (String) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onReturnAfterGame: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onUnfoldedControls: (Boolean) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
@@ -144,6 +149,7 @@ class ModeSettingsActions(
val onWifiDiscoverySettings: () -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
val onDeckyCheck: () -> Unit = {},
val onDeckyBranch: (Int) -> Unit = {},
val onDeckyEnabled: (Boolean) -> Unit = {},
val onDeckyUninstall: () -> Unit = {},
val onPickDeckyPluginZip: () -> Unit = {},
@@ -273,8 +279,10 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
SessionPrefs.CONTROLLER_NONE to stringResource(R.string.second_mode_none),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
note = if (s.steamController == SessionPrefs.CONTROLLER_NONE) stringResource(R.string.common_applies_next_session)
else stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam && s.unfoldedControls != null) ToggleRow(
@@ -423,6 +431,19 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
}
}
ChoiceRow(
host, "decky-channel", stringResource(R.string.decky_channel),
stringResource(R.string.decky_channel_hint),
listOf(
DeckyManager.BRANCH_STABLE to stringResource(R.string.decky_channel_stable),
DeckyManager.BRANCH_PRERELEASE to stringResource(R.string.decky_channel_prerelease),
) + if (s.deckyBranch == DeckyManager.BRANCH_TESTING) listOf(
DeckyManager.BRANCH_TESTING to stringResource(R.string.decky_channel_testing),
) else emptyList(),
s.deckyBranch,
enabled = s.deckyStage == null && !s.deckySessionRunning,
onPick = a.onDeckyBranch,
)
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
@@ -454,6 +475,11 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
if (s.returnAfterGame != null) ToggleRow(
host, "returnaftergame", stringResource(R.string.mode_return_after_game),
stringResource(R.string.mode_return_after_game_hint),
s.returnAfterGame, onChange = a.onReturnAfterGame,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
@@ -529,6 +555,35 @@ internal fun ProtonOptions(
GameEnvironmentRow()
}
@Composable
internal fun DeckyZipWarningDialog(onConfirm: () -> Unit, onCancel: () -> Unit) {
val context = androidx.compose.ui.platform.LocalContext.current
var left by remember { mutableIntStateOf(if (SessionPrefs.deckyZipWarningSeen(context)) 0 else DECKY_ZIP_WARNING_SECONDS) }
androidx.compose.runtime.LaunchedEffect(Unit) {
while (left > 0) {
kotlinx.coroutines.delay(1000L)
left--
}
SessionPrefs.setDeckyZipWarningSeen(context)
}
AlertDialog(
onDismissRequest = onCancel,
title = { Text(stringResource(R.string.decky_zip_warning_title)) },
text = { Text(stringResource(R.string.decky_zip_warning_text)) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = onConfirm, enabled = left == 0) {
Text(
if (left > 0) androidx.compose.ui.res.pluralStringResource(R.plurals.decky_zip_warning_wait, left, left)
else stringResource(R.string.decky_zip_warning_continue),
)
}
},
dismissButton = { androidx.compose.material3.TextButton(onClick = onCancel) { Text(stringResource(R.string.common_cancel)) } },
)
}
private const val DECKY_ZIP_WARNING_SECONDS = 5
@Composable
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -1,17 +1,21 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import android.app.Presentation
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewConfiguration
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.FrameLayout
@@ -33,9 +37,9 @@ import com.termux.terminal.TerminalSessionClient
import com.termux.view.TerminalView
import com.termux.view.TerminalViewClient
internal class SecondScreenControls(
context: Context,
class SecondScreenPresentation(
private val sessionActivity: SessionActivity,
display: Display,
private val sendPointer: (x: Float, y: Float, width: Float, height: Float) -> Unit,
private val sendButton: (button: Int, pressed: Boolean) -> Unit,
private val sendWheel: (steps: Int) -> Unit,
@@ -43,8 +47,12 @@ internal class SecondScreenControls(
private val onQam: () -> Unit,
private val onClose: () -> Unit,
private val pad: PadBridge? = null,
) : LinearLayout(context) {
private val root get() = this
) : Presentation(sessionActivity, display) {
private val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.rgb(15, 18, 22))
isFocusableInTouchMode = true
}
private var currentMode = SecondScreenMode.NONE
private var keyboardHost: KeyboardHost? = null
private var terminalSession: TerminalSession? = null
@@ -52,22 +60,35 @@ internal class SecondScreenControls(
private var terminalPid = -1
private var ctrlActive = false
private var altActive = false
private var created = false
init {
orientation = VERTICAL
setBackgroundColor(Color.rgb(15, 18, 22))
isFocusableInTouchMode = true
setCancelable(false)
setCanceledOnTouchOutside(false)
setOnDismissListener { closeTerminal() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
created = true
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
setContentView(root)
applyImmersiveMode()
renderMode()
}
fun showMode(mode: SecondScreenMode) {
if (currentMode == mode) return
if (currentMode == mode && isShowing) return
currentMode = mode
renderMode()
if (created) renderMode()
}
fun closeControls() {
closeTerminal()
keyboardHost?.hide()
keyboardHost = null
dismiss()
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -77,14 +98,24 @@ internal class SecondScreenControls(
return super.dispatchKeyEvent(event)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
applyImmersiveMode()
sessionActivity.refreshClipboard()
}
}
private fun renderMode() {
keyboardHost?.hide()
keyboardHost = null
closeTerminal()
root.removeAllViews()
if (currentMode == SecondScreenMode.NONE) return
val mode = if (currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad) SecondScreenMode.NONE
else currentMode
if (mode == SecondScreenMode.NONE) return
when (currentMode) {
when (mode) {
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
SecondScreenMode.TERMINAL -> renderTerminal()
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams())
@@ -262,6 +293,16 @@ internal class SecondScreenControls(
private fun weightParams() = LinearLayout.LayoutParams(-1, 0, 1f)
private fun applyImmersiveMode() {
@Suppress("DEPRECATION")
window?.decorView?.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
}
private inner class TrackpadView(context: Context) : View(context) {
private val gestures = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(),
object : PointerGestures.Listener {
@@ -1,139 +0,0 @@
package com.droiddeck.launcher.ui
import android.app.Activity
import android.app.ActivityOptions
import android.content.Intent
import android.os.Bundle
import android.view.Display
import android.view.View
import android.view.WindowManager
import com.droiddeck.launcher.SessionActivity
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.session.SessionState
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicLong
class SecondScreenWindow(
private val sessionActivity: SessionActivity,
val display: Display,
private val sendPointer: (Float, Float, Float, Float) -> Unit,
private val sendButton: (Int, Boolean) -> Unit,
private val sendWheel: (Int) -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
private val onClose: () -> Unit,
private val pad: PadBridge? = null,
) {
private val token = tokens.incrementAndGet()
private var activity = WeakReference<SecondScreenActivity>(null)
private var controls: SecondScreenControls? = null
private var mode = SecondScreenMode.NONE
private var launching = false
private var closed = false
val window get() = activity.get()?.window
val isShowing get() = !closed && (launching || activity.get()?.isFinishing == false)
fun show() {
if (isShowing || closed) return
owners[token] = WeakReference(this)
launching = true
try {
val intent = Intent(sessionActivity, SecondScreenActivity::class.java)
.putExtra(OWNER, token)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
sessionActivity.startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(display.displayId).toBundle())
} catch (error: Exception) {
launching = false
owners.remove(token)
throw error
}
}
fun showMode(mode: SecondScreenMode) {
this.mode = mode
controls?.showMode(mode)
}
fun closeControls() {
closed = true
launching = false
owners.remove(token)
controls?.closeControls()
controls = null
activity.get()?.finishAndRemoveTask()
activity.clear()
}
internal fun attach(host: SecondScreenActivity): View {
controls?.closeControls()
activity = WeakReference(host)
launching = false
return SecondScreenControls(host, sessionActivity, sendPointer, sendButton, sendWheel,
onSteamMenu, onQam, onClose, pad).also {
controls = it
it.showMode(mode)
}
}
internal fun detach(host: SecondScreenActivity) {
if (activity.get() !== host) return
controls?.closeControls()
controls = null
activity.clear()
if (host.isFinishing && !closed) onClose()
}
internal fun refreshClipboard() = sessionActivity.refreshClipboard()
companion object {
private const val OWNER = "secondScreenOwner"
private val tokens = AtomicLong()
private val owners = mutableMapOf<Long, WeakReference<SecondScreenWindow>>()
internal fun find(intent: Intent): SecondScreenWindow? =
owners[intent.getLongExtra(OWNER, 0)]?.get()?.takeUnless { it.closed }
}
}
class SecondScreenActivity : Activity() {
private var owner: SecondScreenWindow? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val host = SecondScreenWindow.find(intent)
if (host == null || !SessionState.running) {
finishAndRemoveTask()
return
}
owner = host
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
setContentView(host.attach(this))
applyImmersiveMode()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
applyImmersiveMode()
owner?.refreshClipboard()
}
}
@Deprecated("Back is handled by the session controls")
override fun onBackPressed() = Unit
override fun onDestroy() {
owner?.detach(this)
owner = null
super.onDestroy()
}
private fun applyImmersiveMode() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
}
}
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.filled.Pause
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -68,6 +69,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -78,21 +81,93 @@ import androidx.compose.ui.text.style.TextOverflow
@Composable
fun HudText(text: String) {
fun HudText(text: String, graph: FloatArray = FloatArray(0), color: String = "mint", opacity: Int = 70) {
if (text.isEmpty() && graph.size < 2) return
val accent = hudInk(color)
val wash = (opacity.coerceIn(30, 100) / 100f)
val ink = accent.copy(alpha = (0.5f + wash * 0.5f).coerceIn(0.62f, 1f))
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.White,
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.padding(12.dp)
.background(Color(0x8C000000))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
.background(
Brush.verticalGradient(
listOf(accent.copy(alpha = wash * 0.28f), Color.Black.copy(alpha = wash * 0.72f)),
),
RoundedCornerShape(8.dp),
)
.padding(horizontal = 8.dp, vertical = 5.dp),
) {
if (text.isNotEmpty()) Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = ink,
textAlign = TextAlign.End,
)
if (graph.size >= 2) {
if (text.isNotEmpty()) Spacer(Modifier.height(4.dp))
HudGraph(graph, accent, wash)
}
}
}
}
@Composable
private fun HudGraph(samples: FloatArray, accent: Color, wash: Float) {
Canvas(Modifier.width(148.dp).height(44.dp)) {
val maxMs = (samples.maxOrNull() ?: 16.7f).coerceAtLeast(16.7f)
val step = size.width / (samples.size - 1)
fun yOf(ms: Float) = size.height * (1f - (ms / maxMs).coerceIn(0f, 1f))
val fillAlpha = wash * 0.5f
val fill = Path().apply {
moveTo(0f, size.height)
samples.forEachIndexed { i, ms -> lineTo(i * step, yOf(ms)) }
lineTo(size.width, size.height)
close()
}
drawPath(
fill,
Brush.verticalGradient(listOf(HUD_SLOW.copy(alpha = fillAlpha), accent.copy(alpha = fillAlpha * 0.45f))),
)
val guide = yOf(16.7f)
drawLine(Color.White.copy(alpha = 0.32f), Offset(0f, guide), Offset(size.width, guide), strokeWidth = 1f)
for (i in 0 until samples.lastIndex) {
val tone = ((samples[i] - 8f) / 25f).coerceIn(0f, 1f)
drawLine(
mix(accent, HUD_SLOW, tone).copy(alpha = 0.55f + wash * 0.4f),
Offset(i * step, yOf(samples[i])),
Offset((i + 1) * step, yOf(samples[i + 1])),
strokeWidth = 2.5f,
cap = StrokeCap.Round,
)
}
}
}
private fun hudInk(color: String): Color = Color(
when (color) {
"white" -> 0xFFF2F5F8
"sky" -> 0xFF66C0F4
"amber" -> 0xFFFFB74D
"violet" -> 0xFFD0A2FF
else -> 0xFF3DDC84
}.toInt(),
)
private fun mix(from: Color, to: Color, t: Float): Color {
val u = t.coerceIn(0f, 1f)
return Color(
red = from.red + (to.red - from.red) * u,
green = from.green + (to.green - from.green) * u,
blue = from.blue + (to.blue - from.blue) * u,
alpha = 1f,
)
}
private val HUD_SLOW = Color(0xFFFF5C6C.toInt())
private class LoadStage(@androidx.annotation.StringRes val label: Int, @androidx.annotation.StringRes val detail: Int)
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
@@ -110,6 +110,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -195,6 +196,11 @@ class DrawerActions(
val isHomeApp: Boolean,
val androidApps: List<HomeApp.LaunchableApp>,
val hudOn: Boolean,
val hudFps: Boolean = true,
val hudBattery: Boolean = true,
val hudGraph: Boolean = true,
val hudColor: String = SessionPrefs.HUD_COLOR_MINT,
val hudOpacity: Int = SessionPrefs.HUD_OPACITY_DEFAULT,
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
@@ -222,6 +228,11 @@ class DrawerActions(
val secondScreenDisplays: List<SecondScreenDisplay>,
val selectedSecondScreenDisplay: Int,
val onHud: (Boolean) -> Unit,
val onHudFps: (Boolean) -> Unit = {},
val onHudBattery: (Boolean) -> Unit = {},
val onHudGraph: (Boolean) -> Unit = {},
val onHudColor: (String) -> Unit = {},
val onHudOpacity: (Int) -> Unit = {},
val onFrameGenPick: (FrameGen.Mode) -> Unit,
val onImportLossless: () -> Unit,
val onKeyboard: () -> Unit,
@@ -308,7 +319,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
val pal = LocalPalette.current
val host = rememberMenuHost()
val (glasses, glassesSettings) = rememberGlasses()
val glassesPages = glassesTabs(glasses)
val glassesPages = glassesTabs(glasses) + (GlassesTab.CONTROLLERS to stringResource(R.string.vr_controllers_tab))
var selectedGlassesTab by rememberSaveable { mutableStateOf(GlassesTab.DISPLAY) }
val glassesTab = selectedGlassesTab.takeIf { key -> glassesPages.any { it.first == key } } ?: GlassesTab.DISPLAY
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
@@ -462,6 +473,30 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
SettingsGroup(stringResource(R.string.drawer_page_display)) {
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
if (a.hudOn) {
ToggleRow(host, "hud-fps", stringResource(R.string.drawer_hud_fps), null, a.hudFps,
chipModifier = focus.track(page, "hud-fps"), onChange = a.onHudFps)
ToggleRow(host, "hud-battery", stringResource(R.string.drawer_hud_battery), null, a.hudBattery,
chipModifier = focus.track(page, "hud-battery"), onChange = a.onHudBattery)
ToggleRow(host, "hud-graph", stringResource(R.string.drawer_hud_graph), null, a.hudGraph,
chipModifier = focus.track(page, "hud-graph"), onChange = a.onHudGraph)
ChoiceRow(
host, "hud-color", stringResource(R.string.drawer_hud_color), null,
listOf(
SessionPrefs.HUD_COLOR_MINT to stringResource(R.string.drawer_hud_color_mint),
"white" to stringResource(R.string.drawer_hud_color_white),
"sky" to stringResource(R.string.drawer_hud_color_sky),
"amber" to stringResource(R.string.drawer_hud_color_amber),
"violet" to stringResource(R.string.drawer_hud_color_violet),
),
a.hudColor, chipModifier = focus.track(page, "hud-color"), onPick = a.onHudColor,
)
SliderRow(
stringResource(R.string.drawer_hud_opacity), null, a.hudOpacity,
SessionPrefs.HUD_OPACITY_RANGE, step = 10, format = { "$it%" },
modifier = focus.track(page, "hud-opacity"), onChange = a.onHudOpacity,
)
}
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
@@ -557,7 +592,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
SessionDrawerPage.SECOND_SCREEN -> {
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.GLASSES -> {
SessionDrawerPage.GLASSES -> if (glassesTab == GlassesTab.CONTROLLERS) {
VrControllerGroups(host) { key -> focus.track(page, key) }
} else {
GlassesGroups(host, glasses, glassesSettings, { key -> focus.track(page, key) }, onRecenter = a.onGlassesRecenter,
tab = glassesTab, outputActive = a.glassesOutput, steamVrRunning = a.steamVr == SteamVr.Status.RUNNING,
steamVr = { SteamVrRow(a) })
@@ -1379,6 +1416,7 @@ internal fun ComponentDownloadsDrawerGroup(
) {
val colors = MaterialTheme.colorScheme
val installed = packages.map { it.file }.toSet()
val nightlyPicked = remember { mutableStateMapOf<String, Boolean>() }
SettingsGroup(stringResource(R.string.drawer_component_downloads)) {
Text(stringResource(R.string.drawer_component_downloads_hint), fontSize = 12.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
@@ -1402,21 +1440,40 @@ internal fun ComponentDownloadsDrawerGroup(
}
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null },
title = ComponentsManager.LABEL.getValue(comp)) { firstItemFocus ->
val firstAvailable = items.firstOrNull { ComponentsManager.safeName(it.file) !in installed && it.file !in downloads }
items.forEach { item ->
// A nightly paired with a stable build rides on that row; Stable/Nightly tabs beside the row pick which one.
val pairs = ComponentsManager.nightlyPairs(items)
val rows = items.filter { it !in pairs.values }
fun shown(row: ComponentsManager.CatalogItem) = pairs[row]?.takeIf { nightlyPicked[row.file] == true } ?: row
val firstAvailable = rows.firstOrNull { shown(it).let { s -> ComponentsManager.safeName(s.file) !in installed && s.file !in downloads } }
rows.forEach { row ->
val item = shown(row)
val nightly = pairs[row]
val stored = ComponentsManager.safeName(item.file) in installed
val progress = downloads[item.file]
MenuItem(item.file.removeSuffix(".wcp"), checked = stored,
enabled = !stored && progress == null,
detail = when {
stored -> stringResource(R.string.wincomp_downloaded)
progress != null -> if (progress < 0) stringResource(R.string.wincomp_downloading, item.file.removeSuffix(".wcp")) else "$progress%"
else -> item.release + " · " + stringResource(R.string.common_size_mb, item.size / 1048576.0)
},
focusRequester = if (item == firstAvailable) firstItemFocus else null,
) {
onDownload(item)
host.open = null
val choose = @Composable { rowModifier: Modifier ->
MenuItem(item.file.removeSuffix(".wcp"), checked = stored,
enabled = !stored && progress == null,
detail = when {
stored -> stringResource(R.string.wincomp_downloaded)
progress != null -> if (progress < 0) stringResource(R.string.wincomp_downloading, item.file.removeSuffix(".wcp")) else "$progress%"
else -> item.release + " · " + stringResource(R.string.common_size_mb, item.size / 1048576.0)
},
focusRequester = if (row == firstAvailable) firstItemFocus else null,
modifier = rowModifier,
) {
onDownload(item)
host.open = null
}
}
if (nightly == null) choose(Modifier)
else Row(verticalAlignment = Alignment.CenterVertically) {
choose(Modifier.weight(1f))
TabStrip(
listOf(stringResource(R.string.comp_channel_stable), stringResource(R.string.comp_channel_nightly)),
selected = if (item === nightly) 1 else 0,
onSelect = { nightlyPicked[row.file] = it == 1 },
bumpers = false, compact = true, modifier = Modifier.padding(start = 6.dp, end = 4.dp),
)
}
}
}
@@ -157,6 +157,7 @@ internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modif
internal fun TabStrip(
tabs: List<String>, selected: Int, onSelect: (Int) -> Unit,
modifier: Modifier = Modifier, focusRequesters: List<FocusRequester>? = null,
bumpers: Boolean = true, compact: Boolean = false,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -164,9 +165,9 @@ internal fun TabStrip(
val scroll = rememberScrollState()
var tight by remember(tabs) { mutableStateOf(false) }
LaunchedEffect(scroll.maxValue) { if (scroll.maxValue in 1 until Int.MAX_VALUE) tight = true }
val tabPadding = if (tight) 10.dp else 16.dp
val tabPadding = if (compact) 10.dp else if (tight) 10.dp else 16.dp
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) {
BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
if (bumpers) BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
Row(
modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape)
.horizontalScroll(scroll).padding(3.dp),
@@ -180,7 +181,7 @@ internal fun TabStrip(
val reveal = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
if (on) LaunchedEffect(Unit) { reveal.bringIntoView() }
Text(
label, fontSize = 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
label, fontSize = if (compact) 12.sp else 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier
.bringIntoViewRequester(reveal)
@@ -191,11 +192,11 @@ internal fun TabStrip(
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = tabPadding, vertical = 12.dp),
.padding(horizontal = tabPadding, vertical = if (compact) 6.dp else 12.dp),
)
}
}
BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) }
if (bumpers) BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) }
}
}
@@ -115,13 +115,14 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
drop = drop.takeIf { headset },
overlay = { lifted ->
if (headset) DropSheet(
drop, glassesTabs(glasses), covered = lifted,
drop, glassesTabs(glasses) + (GlassesTab.CONTROLLERS to stringResource(R.string.vr_controllers_tab)), covered = lifted,
button = { enabled ->
PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy && glasses.usable, large = true, icon = Icons.Filled.PlayArrow, fill = true,
modifier = Modifier.fillMaxWidth().testTag("play-steamvr")) { if (enabled) { drop.open = false; a.onPlaySteamVr() } }
},
) { tab ->
GlassesGroups(rememberMenuHost(), glasses, glassesSettings,
if (tab == GlassesTab.CONTROLLERS) VrControllerGroups(rememberMenuHost())
else GlassesGroups(rememberMenuHost(), glasses, glassesSettings,
onRecenter = GlassesMenuMirror::recenter, inSession = false, tab = tab)
}
},
@@ -51,7 +51,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoresState
@@ -68,7 +67,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
val narrow = LocalNarrowPane.current
val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700
val id = key.substringAfter(':')
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
val item = StoresState.detailItem(store, id)
val installed = StoresState.installedGame(store, id)
val rawDownload = StoresState.download("${store.id}:$id")
val download = rawDownload?.takeIf { it.isActive }
@@ -86,18 +85,23 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
.clip(Shape16).background(artBrush(hueOf(title))),
) {
val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
// The card it came from keeps its own bitmap under the hero until the wide art is decoded.
val heroArt = cardArtRequest(art, item?.tallImageUrl)
if (heroArt != null) AsyncImage(model = heroArt, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent)))
if (scores != null && !stacked) {
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f))))
HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
val passName = item?.includedWith.orEmpty()
val state = when {
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location
failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location
item?.subscription == true -> "PC Game Pass"
item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
item?.subscription == true -> passName.ifBlank { "PC Game Pass" }
passName.isNotBlank() -> "Available with $passName"
item?.accessible == true -> stringResource(R.string.stores_eyebrow_library)
else -> stringResource(R.string.stores_eyebrow_store)
}
@@ -153,14 +157,25 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key)
}
}
item?.accessible == true && StoresState.isUnfinished(item) -> {
item?.installable == true && StoresState.isUnfinished(item) -> {
InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) }
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
}
item?.accessible == true -> InstallButton(
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
leadOn,
) { StoresState.requestInstall(ctx, item) }
item?.installable == true -> {
InstallButton(
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
leadOn,
) { StoresState.requestInstall(ctx, item) }
if (!item.accessible) {
SecondaryButton(
when {
item.isFree -> stringResource(R.string.stores_get_free)
item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice)
else -> stringResource(R.string.stores_view_on, store.shortLabel)
},
) { openStoreUrl(ctx, item) }
}
}
item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) }
item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true, modifier = leadOn) { openStoreUrl(ctx, item) }
}
@@ -171,7 +186,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
}
if (StoresMotion.heroLands) hero() else Rise(1) { hero() }
if (scores != null) Rise(2) {
val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id }
val listed = (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty()
val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty()
val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty()
@@ -84,6 +84,7 @@ import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
@@ -495,6 +496,7 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val st = s.value
val context = androidx.compose.ui.platform.LocalContext.current
SettingsRow(stringResource(R.string.stores_tabs_arrange), null) {
RoundAction(Icons.Filled.SwapHoriz, stringResource(R.string.stores_tabs_arrange), size = 36.dp) {
StoresMotion.fold()
@@ -510,8 +512,33 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
SettingsRow(stringResource(R.string.stores_show_mature), null) {
ToggleSwitch(st.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
}
var langOpen by remember { mutableStateOf(false) }
val storedLanguage = SessionPrefs.storesInstallLanguage(context)
var language by remember(storedLanguage, StoreLanguage.preferredCode(context)) { mutableStateOf(storedLanguage) }
SettingsRow(stringResource(R.string.stores_install_language), stringResource(R.string.stores_install_language_hint), stacked = true) {
Box {
ValueChip(
if (language.isEmpty()) stringResource(R.string.stores_install_language_system)
else StoreLanguage.displayName(language),
langOpen,
) { langOpen = !langOpen }
AnchoredMenu(langOpen, { langOpen = false }, title = stringResource(R.string.stores_install_language)) { first ->
val options = listOf("") + StoreLanguage.KNOWN
options.forEachIndexed { i, code ->
MenuItem(
if (code.isEmpty()) stringResource(R.string.stores_install_language_system) else StoreLanguage.displayName(code),
checked = language == code,
focusRequester = if (i == 0) first else null,
) {
language = code
SessionPrefs.setStoresInstallLanguage(context, code)
langOpen = false
}
}
}
}
}
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) }
val context = androidx.compose.ui.platform.LocalContext.current
var gogAchievements by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(com.droiddeck.launcher.stores.gog.GogComet.enabled(context)) }
androidx.compose.runtime.LaunchedEffect(Unit) {
if (gogAchievements) com.droiddeck.launcher.core.AddonPack.COMET.ensure(context)
@@ -25,7 +25,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -42,8 +45,12 @@ import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.formatBytes
import com.droiddeck.launcher.stores.gog.GogDownloadManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
@@ -55,7 +62,19 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
val remembered = remember { SessionPrefs.storesInstallTarget(ctx) }
val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0)
val focus = remember { List(targets.size) { FocusRequester() } }
val askLanguage = StoreLanguage.selectable(item.store)
val preferredLanguage = StoreLanguage.preferredCode(ctx)
var languages by remember { mutableStateOf(StoreLanguage.fallbackList(item.store)) }
var chosen by remember(preferredLanguage) { mutableStateOf(StoreLanguage.pick(languages, preferredLanguage)) }
var menuOpen by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus[defaultIndex] } }
LaunchedEffect(item.key) {
if (item.store != com.droiddeck.launcher.stores.Store.GOG) return@LaunchedEffect
val listed = withContext(Dispatchers.IO) { GogDownloadManager.listLanguages(ctx, item.id) }
if (listed.isEmpty()) return@LaunchedEffect
languages = listed
chosen = StoreLanguage.pick(listed, chosen)
}
AppDialog(shown, close, "installWhere", wide = false, maxWidth = if (targets.size > 2) 600.dp else 440.dp) {
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
@@ -65,11 +84,27 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
)
Text(
stringResource(R.string.common_cancel), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant,
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).controllerConfirm(onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
Rise(1) {
if (askLanguage) Rise(1) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.stores_install_language), fontSize = 13.sp, color = colors.onSurfaceVariant)
Box(Modifier.weight(1f)) {
ValueChip(StoreLanguage.displayName(chosen).ifEmpty { chosen }, menuOpen, modifier = Modifier.fillMaxWidth()) { menuOpen = !menuOpen }
AnchoredMenu(menuOpen, { menuOpen = false }, title = stringResource(R.string.stores_install_language), compact = true) { first ->
languages.forEachIndexed { i, code ->
MenuItem(StoreLanguage.displayName(code).ifEmpty { code }, checked = code == chosen, focusRequester = if (i == 0) first else null) {
chosen = code
menuOpen = false
}
}
}
}
}
}
Rise(if (askLanguage) 2 else 1) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
targets.forEachIndexed { i, t ->
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
@@ -89,7 +124,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
warning = isLow, modifier = Modifier.weight(1f),
) {
SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath)
StoresState.install(ctx, item, t.root)
StoresState.install(ctx, item, t.root, if (askLanguage) chosen else null)
close()
}
}
@@ -30,6 +30,7 @@ import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.epic.EpicClient
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -201,12 +202,12 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
val colors = MaterialTheme.colorScheme
val narrow = LocalNarrowPane.current
val padH = if (narrow) 14.dp else 22.dp
var chip by rememberSaveable { mutableStateOf(Store.GOG.id) }
val tabs = remember {
androidx.compose.runtime.mutableStateListOf<StoreTab>().apply {
addAll(arrangeStoreTabs(SessionPrefs.storesTabOrder(ctx), SessionPrefs.storesTabHidden(ctx)))
}
}
var chip by rememberSaveable { mutableStateOf(tabs.firstOrNull { it.shown }?.store?.id ?: Store.GOG.id) }
var arranging by remember { mutableStateOf(false) }
val shownStores = tabs.filter { it.shown }.map { it.store }
fun commitTabs() {
@@ -543,7 +544,10 @@ private fun InstallWhere(item: CatalogItem) {
val ctx = LocalContext.current
val pal = LocalPalette.current
val from = remember(item) { StoresMotion.recentInstall() }
if (from == null) { InstallWhereDialog(item) { StoresState.pendingInstall = null }; return }
if (from == null || StoreLanguage.selectable(item.store)) {
InstallWhereDialog(item) { StoresState.pendingInstall = null }
return
}
val title = stringResource(R.string.stores_install_title, item.title)
val internalLabel = stringResource(R.string.stores_target_internal)
val label = StoresMotion.installLabel
@@ -638,8 +642,9 @@ private fun StoreDot(store: Store, signedIn: Boolean) {
Box(
Modifier.size(12.dp)
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot),
) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) }
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot)
.background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
)
}
@Composable
@@ -1006,8 +1011,11 @@ private fun Storefront(
val density = androidx.compose.ui.platform.LocalDensity.current
val focus = LocalFrontFocus.current
val scope = rememberCoroutineScope()
val headerItems = 1 + (if (sizeSortPending) 1 else 0) +
(if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0)
// A bar only where the tab has nothing else on it: shelves, the shelf of owned games, or a grid still loading.
val gridEmpty = if (showGrid) visible.isEmpty() else (shelves == null || shelves.isEmpty) && library.isEmpty()
val barLine = StoresState.status[store]?.takeIf { gridEmpty && syncCount(it) != null }
val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (barLine != null) 1 else 0) +
(if (StoresState.problems[store] != null) 1 else 0)
val listMode = s.storesView == "list" && tab == "library"
val capsules = s.storesView == "capsules" && tab == "library"
val tileWidth = if (capsules) CapsuleWidth else CardWidth
@@ -1090,7 +1098,7 @@ private fun Storefront(
Text(stringResource(R.string.store_section_loading), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
StoresState.status[store]?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } }
barLine?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } }
StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } }
when (tab) {
"library" -> if (!loaded) Unit
@@ -1479,22 +1487,35 @@ private fun GameCard(
}
}
private fun syncCount(line: String): Pair<Int, Int>? {
val match = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line) ?: return null
val done = match.groupValues[1].toIntOrNull() ?: return null
val total = match.groupValues[2].toIntOrNull()?.takeIf { it > 0 } ?: return null
return done to total
}
@Composable
private fun SyncBar(line: String) {
val count = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line)
val done = count?.groupValues?.get(1)?.toIntOrNull()
val total = count?.groupValues?.get(2)?.toIntOrNull()?.takeIf { it > 0 }
val count = syncCount(line) ?: return
val done = count.first
val total = count.second
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Box(Modifier.weight(1f)) {
if (done != null && total != null) androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp))
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(3.dp))
}
if (done != null && total != null) Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
Box(Modifier.weight(1f)) { androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp)) }
Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
private val CardStrip = 26.dp
@Composable
internal fun cardArtRequest(url: String?, cover: String?): coil.request.ImageRequest? {
val ctx = LocalContext.current
val under = cover?.takeIf { it != url }
return remember(url, under) {
url?.let { coil.request.ImageRequest.Builder(ctx).data(it).apply { if (under != null) placeholderMemoryCacheKey(under) }.build() }
}
}
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
private fun CardFace(
@@ -1502,26 +1523,27 @@ private fun CardFace(
marker: @Composable () -> Unit,
) {
val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl
val art = cardArtRequest(url, item.tallImageUrl)
val blurs = android.os.Build.VERSION.SDK_INT >= 31
var landscape by remember(url) { mutableStateOf(false) }
BoxWithConstraints(Modifier.fillMaxWidth()) {
val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip
Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
if (url != null && capsule && landscape) {
if (art != null && capsule && landscape) {
// Stores without portrait covers (itch) would lose most of the art to a portrait crop.
AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp))
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp))
Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f)))
AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip))
} else if (url != null) AsyncImage(
model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(),
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip))
} else if (art != null) AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(),
onSuccess = { r ->
val art = r.result.drawable
if (capsule && art.intrinsicWidth > 0 && art.intrinsicHeight > 0) landscape = art.intrinsicWidth > art.intrinsicHeight * 0.95f
val drawn = r.result.drawable
if (capsule && drawn.intrinsicWidth > 0 && drawn.intrinsicHeight > 0) landscape = drawn.intrinsicWidth > drawn.intrinsicHeight * 0.95f
},
)
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) {
if (url != null) AsyncImage(
model = url, contentDescription = null, contentScale = ContentScale.Crop,
if (art != null) AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().wrapContentHeight(Alignment.Bottom, unbounded = true).height(total).blur(24.dp),
)
Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.45f else 0.7f)))
@@ -1574,9 +1596,10 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi
@Composable
internal fun CardArt(item: CatalogItem, modifier: Modifier) {
val url = item.imageUrl ?: item.tallImageUrl
val art = cardArtRequest(url, item.tallImageUrl)
val colors = MaterialTheme.colorScheme
Box(modifier.background(colors.surfaceVariant).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
else Text(item.title, fontSize = 10.sp, fontWeight = FontWeight.Black, color = Color.White, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(6.dp))
Spacer(Modifier.matchParentSize().background(androidx.compose.ui.graphics.Brush.verticalGradient(0.6f to Color.Transparent, 1f to colors.surface.copy(alpha = 0.85f))))
}
@@ -69,7 +69,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
val actions = mouse + ACTION_SHOW_KEYBOARD + GestureKeys.codes.keys.map { "key_$it" } + ACTION_KEY_TILDE
val p = LocalPalette.current
GestureDialogFrame(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_editor_subtitle), onDismiss,
GestureDialogFrame(onDismiss,
footer = {
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) }
@@ -83,15 +83,6 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}, modifier = Modifier.testTag("gesture-save")) { Text(stringResource(R.string.gesture_save_changes)) }
}
}) {
Column(Modifier.padding(horizontal = 20.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default"))
if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game"))
if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) }
}
Text(stringResource(if (!forGame) R.string.gesture_scope_default else if (gameDraft == null) R.string.gesture_inheriting else R.string.gesture_scope_game),
color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 12.dp))
}
ScrollableTabRow(tab, edgePadding = 12.dp, containerColor = p.surface, divider = { HorizontalDivider(color = p.line) }) {
tabs.forEachIndexed { index, title ->
Tab(tab == index, onClick = { tab = index }, text = { Text(stringResource(title)) }, selectedContentColor = p.onBackground,
@@ -100,9 +91,16 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
key(tab) {
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default"))
if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game"))
}
if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) }
}
when (tab) {
0 -> {
GestureSection(R.string.gesture_tab_one, R.string.gesture_one_hint)
GestureSection(R.string.gesture_tab_one)
GestureCard(R.string.gesture_tap, config.tapEnabled, { change(config.copy(tapEnabled = it)) }) {
GestureActionField("tap", R.string.gesture_tap, config.tapAction, actions) { change(config.copy(tapAction = it)) }
}
@@ -118,7 +116,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
}
1 -> {
GestureSection(R.string.gesture_tab_two, R.string.gesture_two_hint)
GestureSection(R.string.gesture_tab_two)
GestureCard(R.string.gesture_two_tap, config.twoFingerTapEnabled, { change(config.copy(twoFingerTapEnabled = it)) }) {
GestureActionField("two-tap", R.string.gesture_two_tap, config.twoFingerTapAction, actions) { change(config.copy(twoFingerTapAction = it)) }
}
@@ -134,7 +132,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
}
2 -> {
GestureSection(R.string.gesture_tab_three, R.string.gesture_three_hint)
GestureSection(R.string.gesture_tab_three)
GestureCard(R.string.gesture_three_tap, config.threeFingerTapEnabled, { change(config.copy(threeFingerTapEnabled = it)) }) {
GestureActionField("three-tap", R.string.gesture_three_tap, config.threeFingerTapAction, actions) { change(config.copy(threeFingerTapAction = it)) }
}
@@ -147,16 +145,13 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
}
3 -> {
GestureSection(R.string.gesture_tab_options, R.string.gesture_options_hint)
GestureSection(R.string.gesture_tab_options)
GestureSettingsCard(R.string.gesture_movement) {
Text(stringResource(R.string.gesture_movement_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
GestureChoiceChips(config.mouseDragMovementMode, listOf(MOUSE_DRAG_MOVEMENT_DIRECT to stringResource(R.string.gesture_direct), MOUSE_DRAG_MOVEMENT_RELATIVE to stringResource(R.string.gesture_relative))) { change(config.copy(mouseDragMovementMode = it)) }
Text(stringResource(R.string.gesture_middle_relative_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
}
GestureSettingsCard(R.string.gesture_recognition) {
Text(stringResource(R.string.gesture_threshold, config.gestureThreshold), style = MaterialTheme.typography.bodyMedium)
Slider(config.gestureThreshold.toFloat(), { change(config.copy(gestureThreshold = it.toInt())) }, valueRange = 10f..100f, steps = 17)
Text(stringResource(R.string.gesture_threshold_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
}
GestureSettingsCard(R.string.gesture_feedback) {
GestureToggle(R.string.gesture_debug, config.showGestureDebugOverlay) { change(config.copy(showGestureDebugOverlay = it)) }
@@ -170,7 +165,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
@Composable
private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () -> Unit,
private fun GestureDialogFrame(onDismiss: () -> Unit,
footer: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
val p = LocalPalette.current
// The app window does not opt into RTL, so these dialogs follow their resource locale explicitly.
@@ -181,15 +176,8 @@ private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () ->
Surface(Modifier.widthIn(max = 1040.dp).fillMaxWidth().fillMaxHeight(), shape = RoundedCornerShape(22.dp), color = p.background,
contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) {
Column {
Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(12.dp), color = p.primary.copy(alpha = 0.14f)) {
Icon(Icons.Default.TouchApp, null, tint = p.primary, modifier = Modifier.padding(10.dp).size(24.dp))
}
Column(Modifier.weight(1f).padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = p.onSurfaceVariant)
}
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) }
Row(Modifier.fillMaxWidth().padding(end = 4.dp), horizontalArrangement = Arrangement.End) {
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) }
}
content()
HorizontalDivider(color = p.line)
@@ -202,11 +190,8 @@ private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () ->
}
@Composable
private fun GestureSection(@StringRes title: Int, @StringRes hint: Int) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(stringResource(hint), style = MaterialTheme.typography.bodyMedium, color = LocalPalette.current.onSurfaceVariant)
}
private fun GestureSection(@StringRes title: Int) {
Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
@Composable
@@ -242,18 +227,18 @@ private fun GestureActionField(id: String, @StringRes gesture: Int, selected: St
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(19.dp), tint = p.onSurfaceVariant)
}
}
if (open) GestureActionPicker(choices, selected, stringResource(gesture), onDismiss = { open = false }) { open = false; onChoose(it) }
if (open) GestureActionPicker(choices, selected, onDismiss = { open = false }) { open = false; onChoose(it) }
}
@Composable
internal fun GestureActionPicker(choices: List<String>, selected: String, gesture: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
internal fun GestureActionPicker(choices: List<String>, selected: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
var query by rememberSaveable { mutableStateOf("") }
var category by remember { mutableStateOf<GestureActionGroup?>(null) }
val catalog = choices.distinct().map { GestureAction(it, gestureActionTitle(it), gestureActionGroup(it)) }
val groups = GestureActionGroup.entries.filter { group -> catalog.any { it.group == group } }
val shown = filterGestureActions(catalog, query, category)
val p = LocalPalette.current
GestureDialogFrame(stringResource(R.string.gesture_action), stringResource(R.string.gesture_action_for, gesture), onDismiss) {
GestureDialogFrame(onDismiss) {
OutlinedTextField(query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).testTag("gesture-action-search"),
singleLine = true, shape = RoundedCornerShape(12.dp), placeholder = { Text(stringResource(R.string.gesture_search_actions)) },
leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = {
@@ -57,7 +57,8 @@ object SelfInstaller {
}
@Suppress("DEPRECATION")
val flags = if (Build.VERSION.SDK_INT >= 28) {
PackageManager.GET_SIGNING_CERTIFICATES
// Android 9 and 10 only collect an archive's certificates when GET_SIGNATURES is set.
PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES
} else {
PackageManager.GET_SIGNATURES
}
@@ -75,15 +76,18 @@ object SelfInstaller {
throw IOException(context.getString(R.string.selfinst_downgrade, code, BuildConfig.VERSION_CODE))
}
if (Build.VERSION.SDK_INT >= 28) {
val signing = info.signingInfo ?: throw IOException(context.getString(R.string.selfinst_no_signing))
val digests = signing.apkContentsSigners.orEmpty().map { cert ->
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
.joinToString("") { "%02x".format(it) }
}
if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) {
throw IOException(context.getString(R.string.selfinst_wrong_signer))
}
val digests = archiveSignerDigests(info)
if (digests.isEmpty()) throw IOException(context.getString(R.string.selfinst_no_signing))
if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) {
throw IOException(context.getString(R.string.selfinst_wrong_signer))
}
}
@Suppress("DEPRECATION")
internal fun archiveSignerDigests(info: android.content.pm.PackageInfo): List<String> {
val certs = (if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else null) ?: info.signatures
return certs.orEmpty().map { cert ->
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) }
}
}
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">النقر مرة واحدة</string>
<string name="gesture_hold_delay">مهلة الضغط المستمر</string>
<string name="gesture_double_delay">الفاصل بين النقرتين</string>
<string name="gesture_scope_game">ينطبق على اللعبة الحالية</string>
<string name="gesture_scope_default">الإعدادات الافتراضية لجميع الألعاب</string>
<string name="gesture_reset">استعادة إعدادات الإيماءات الأصلية</string>
<string name="gesture_cancel">إلغاء</string>
<string name="gesture_action">اختيار إجراء</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">عجلة الفأرة</string>
<string name="gesture_threshold">عتبة الإيماءات: %1$d بكسل</string>
<string name="gesture_debug">إظهار الإيماءة المكتشفة</string>
<string name="gesture_editor_subtitle">عناصر تحكم الفأرة ولوحة المفاتيح مرتبة حسب الإيماءة</string>
<string name="gesture_tab_one">إصبع واحد</string>
<string name="gesture_tab_two">إصبعان</string>
<string name="gesture_tab_three">ثلاثة أصابع</string>
<string name="gesture_tab_options">خيارات</string>
<string name="gesture_one_hint">اختر إجراء النقر أو السحب أو الضغط المستمر.</string>
<string name="gesture_two_hint">اضبط تحريك الكاميرا والتكبير والنقرات الثانوية.</string>
<string name="gesture_three_hint">اجعل الاختصارات الإضافية في متناولك.</string>
<string name="gesture_options_hint">اضبط الحركة والتعرّف والاستجابة المرئية.</string>
<string name="gesture_defaults">الافتراضية</string>
<string name="gesture_game">هذه اللعبة</string>
<string name="gesture_use_defaults">استخدام الإعدادات الافتراضية</string>
<string name="gesture_inheriting">تُستخدم الإعدادات الافتراضية. يؤدي التعديل إلى إنشاء إعدادات خاصة باللعبة.</string>
<string name="gesture_save_changes">حفظ التغييرات</string>
<string name="gesture_close">إغلاق</string>
<string name="gesture_action_label">الإجراء</string>
<string name="gesture_action_for">لـ %1$s</string>
<string name="gesture_search_actions">البحث عن إجراءات الفأرة أو مفاتيح لوحة المفاتيح</string>
<string name="gesture_clear_search">مسح البحث</string>
<string name="gesture_all_actions">الكل</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">لوحة الأرقام</string>
<string name="gesture_group_symbols">الرموز</string>
<string name="gesture_movement">حركة الفأرة</string>
<string name="gesture_movement_hint">يتبع السحب المباشر موضع اللمس. ويتبع السحب النسبي حركة الإصبع، وهو مفيد عندما تعيد اللعبة الفأرة إلى المنتصف.</string>
<string name="gesture_middle_relative_hint">يتبع تحريك العرض بالزر الأوسط حركة الإصبع دائمًا لتجنّب قفزات المؤشر.</string>
<string name="gesture_direct">مباشر</string>
<string name="gesture_relative">نسبي</string>
<string name="gesture_recognition">التعرّف على الإيماءات</string>
<string name="gesture_threshold_hint">تحرّك هذه المسافة قبل أن تتحول إيماءة بإصبعين أو ثلاثة إلى تحريك العرض أو التكبير.</string>
<string name="gesture_feedback">الاستجابة المرئية</string>
<string name="gesture_mouse_behavior">سلوك زر الفأرة</string>
<string name="gesture_ms">%1$d مللي ثانية</string>
+28 -3
View File
@@ -512,7 +512,9 @@
<string name="comp_delete_original_title">هل تريد حذف هذه النسخة الأصلية؟</string>
<string name="comp_delete_original_body">هذه النسخة الأصلية من %1$s تعود إلى إصدار سابق من %2$s. يتم دائمًا الاحتفاظ بالنسخة الأصلية للإصدار الحالي.</string>
<string name="comp_nightlies_count">في الإصدارات الليلية · %1$d</string>
<string name="comp_refresh_to_list">حدّث لعرض الحزم في الإصدارات الليلية.</string>
<string name="comp_channel_stable">مستقر</string>
<string name="comp_channel_nightly">ليلي</string>
<string name="comp_refresh_to_list">حدّث لعرض الحزم في إصدارات Nightlies وDroidDeck.</string>
<string name="comp_nothing_new">لا جديد منذ آخر فحص.</string>
<string name="comp_import_wcp">استيراد ملف ‎.wcp</string>
<string name="comp_restore_original">استعادة النسخة الأصلية لـ Proton هذا</string>
@@ -590,6 +592,16 @@
<string name="drawer_prev_page">الصفحة السابقة</string>
<string name="drawer_next_page">الصفحة التالية</string>
<string name="drawer_hud">واجهة الأداء</string>
<string name="drawer_hud_fps">معدل الإطارات</string>
<string name="drawer_hud_battery">درجة حرارة البطارية</string>
<string name="drawer_hud_graph">مخطط الأداء</string>
<string name="drawer_hud_color">اللون</string>
<string name="drawer_hud_color_mint">نعناعي</string>
<string name="drawer_hud_color_white">أبيض</string>
<string name="drawer_hud_color_sky">سماوي</string>
<string name="drawer_hud_color_amber">كهرماني</string>
<string name="drawer_hud_color_violet">بنفسجي</string>
<string name="drawer_hud_opacity">العتامة</string>
<string name="drawer_scaling">وضع التحجيم</string>
<string name="drawer_scaling_sharpness">حدة التحجيم</string>
<string name="drawer_effects">مؤثرات الشاشة</string>
@@ -1854,7 +1866,8 @@
<string name="cmgr_original_kept">تم الاحتفاظ بأصل النسخة المثبَّتة: فهو طريق العودة.</string>
<string name="cmgr_not_stored">غير مخزَّن</string>
<string name="cmgr_deleted_original">تم حذف أصل %2$s من %1$s.</string>
<string name="cmgr_nightlies_only">التنزيلات تأتي فقط من إصدارات Nightlies</string>
<string name="cmgr_nightlies_only">التنزيلات تأتي فقط من إصدارات Nightlies وDroidDeck</string>
<string name="cmgr_nightly_replaced">فشل التنزيل: ربما استُبدل هذا الإصدار الليلي بإصدار أحدث. حدّث القائمة وحاول مرة أخرى.</string>
<string name="cmgr_list_predates_checksums">قائمة الحزم هذه أقدم من إضافة المجاميع الاختبارية - حدِّثها وحاوِل مرة أخرى</string>
<string name="cmgr_checksum_mismatch">عدم تطابق المجموع الاختباري</string>
@@ -2110,6 +2123,9 @@
<string name="cloud_last_sync">آخر مزامنة %1$s</string>
<string name="cloud_never">لم تتم المزامنة بعد</string>
<string name="cloud_done">%1$d ملفات</string>
<string name="cloud_up_to_date">محدَّث بالفعل</string>
<string name="cloud_signed_out">سجّل الدخول إلى المتجر لمزامنة الحفظ</string>
<string name="cloud_not_synced">لم تتم مزامنة الحفظ</string>
<string name="stores_eyebrow_installed">مثبَّتة</string>
<string name="stores_eyebrow_library">في مكتبتك</string>
<string name="stores_eyebrow_store">متجر</string>
@@ -2421,7 +2437,7 @@
<string name="touch_done">تم</string>
<string name="touch_save_failed">تعذّر حفظ تخطيط اللمس</string>
<string name="touch_saved">تم حفظ تخطيط اللمس في Steam</string>
<string name="touch_app_id">التطبيق %1$d</string>
<string name="touch_app_id">التطبيق %1$s</string>
<string name="touch_label_hint">التسمية (اختياري)</string>
<string name="touch_icons_missing">لم يتم العثور على أيقونات Steam</string>
<plurals name="touch_icons_found">
@@ -2520,6 +2536,15 @@
<string name="glasses_vr_fov">مجال رؤية SteamVR</string>
<string name="glasses_vr_fov_hint">القيمة الأقل تقرّب SteamVR وتملأ النظارة؛ والأعلى تُظهر المزيد مما حولك.</string>
<string name="glasses_playing">جارٍ اللعب على %1$s</string>
<string name="glasses_phone_recenter">إعادة التوسيط</string>
<string name="glasses_phone_steamvr_menu">قائمة SteamVR</string>
<string name="glasses_phone_switch_sticks">العصي: اليدان ⇄ اللعبة</string>
<string name="glasses_phone_controllers">وحدات التحكم: %1$s</string>
<string name="glasses_phone_keyboard">لوحة المفاتيح</string>
<string name="glasses_phone_session_menu">قائمة الجلسة</string>
<string name="glasses_phone_stop_steamvr">إيقاف SteamVR</string>
<string name="glasses_phone_brightness">السطوع %1$d / %2$d</string>
<string name="glasses_phone_battery">البطارية %1$d%% · %2$d °C</string>
<string name="glasses_calibration">معايرة التتبّع</string>
<string name="glasses_calibration_hint">يقلّل الانجراف ويضبط المرجع الأفقي.</string>
<string name="glasses_calibration_saved_hint">تُطبَّق المعايرة المحفوظة على هذه النظارة.</string>
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Hacer clic una vez</string>
<string name="gesture_hold_delay">Tiempo de pulsación sostenida</string>
<string name="gesture_double_delay">Intervalo de doble toque</string>
<string name="gesture_scope_game">Se aplica al juego actual</string>
<string name="gesture_scope_default">Ajustes predeterminados para todos los juegos</string>
<string name="gesture_reset">Restablecer los ajustes originales de gestos</string>
<string name="gesture_cancel">Cancelar</string>
<string name="gesture_action">Elegir acción</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Rueda del ratón</string>
<string name="gesture_threshold">Umbral de gestos: %1$d px</string>
<string name="gesture_debug">Mostrar gesto reconocido</string>
<string name="gesture_editor_subtitle">Controles de ratón y teclado organizados por gesto</string>
<string name="gesture_tab_one">Un dedo</string>
<string name="gesture_tab_two">Dos dedos</string>
<string name="gesture_tab_three">Tres dedos</string>
<string name="gesture_tab_options">Opciones</string>
<string name="gesture_one_hint">Elige qué debe hacer un toque, arrastre o pulsación sostenida.</string>
<string name="gesture_two_hint">Configura el movimiento de cámara, el zoom y los clics secundarios.</string>
<string name="gesture_three_hint">Ten más accesos directos al alcance de la mano.</string>
<string name="gesture_options_hint">Ajusta el movimiento, el reconocimiento y la respuesta visual.</string>
<string name="gesture_defaults">Predeterminados</string>
<string name="gesture_game">Este juego</string>
<string name="gesture_use_defaults">Usar valores predeterminados</string>
<string name="gesture_inheriting">Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego.</string>
<string name="gesture_save_changes">Guardar cambios</string>
<string name="gesture_close">Cerrar</string>
<string name="gesture_action_label">ACCIÓN</string>
<string name="gesture_action_for">Para %1$s</string>
<string name="gesture_search_actions">Buscar acciones de ratón o teclas del teclado</string>
<string name="gesture_clear_search">Borrar búsqueda</string>
<string name="gesture_all_actions">Todas</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Teclado numérico</string>
<string name="gesture_group_symbols">Símbolos</string>
<string name="gesture_movement">Movimiento del ratón</string>
<string name="gesture_movement_hint">El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón.</string>
<string name="gesture_middle_relative_hint">El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor.</string>
<string name="gesture_direct">Directo</string>
<string name="gesture_relative">Relativo</string>
<string name="gesture_recognition">Reconocimiento de gestos</string>
<string name="gesture_threshold_hint">Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom.</string>
<string name="gesture_feedback">Respuesta visual</string>
<string name="gesture_mouse_behavior">Comportamiento del botón del ratón</string>
<string name="gesture_ms">%1$d ms</string>
+27 -2
View File
@@ -499,7 +499,9 @@
<string name="comp_delete_original_title">¿Eliminar este original?</string>
<string name="comp_delete_original_body">Este original de %1$s es de una versión anterior de %2$s. El original de la versión actual siempre se conserva.</string>
<string name="comp_nightlies_count">En las Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de las Nightlies.</string>
<string name="comp_channel_stable">Estable</string>
<string name="comp_channel_nightly">Nocturna</string>
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de Nightlies y DroidDeck.</string>
<string name="comp_nothing_new">Nada nuevo desde la última comprobación.</string>
<string name="comp_import_wcp">Importar .wcp</string>
<string name="comp_restore_original">Restaurar el original de este Proton</string>
@@ -577,6 +579,16 @@
<string name="drawer_prev_page">Página anterior</string>
<string name="drawer_next_page">Página siguiente</string>
<string name="drawer_hud">HUD de rendimiento</string>
<string name="drawer_hud_fps">Tasa de fotogramas</string>
<string name="drawer_hud_battery">Temperatura de la batería</string>
<string name="drawer_hud_graph">Gráfico de rendimiento</string>
<string name="drawer_hud_color">Color</string>
<string name="drawer_hud_color_mint">Menta</string>
<string name="drawer_hud_color_white">Blanco</string>
<string name="drawer_hud_color_sky">Celeste</string>
<string name="drawer_hud_color_amber">Ámbar</string>
<string name="drawer_hud_color_violet">Violeta</string>
<string name="drawer_hud_opacity">Opacidad</string>
<string name="drawer_scaling">Modo de escalado</string>
<string name="drawer_scaling_sharpness">Nitidez del escalado</string>
<string name="drawer_effects">Efectos de pantalla</string>
@@ -1830,7 +1842,8 @@
<string name="cmgr_original_kept">El original de la versión instalada se conserva: es el camino de vuelta.</string>
<string name="cmgr_not_stored">No guardado</string>
<string name="cmgr_deleted_original">Se eliminó el original de %1$s de %2$s.</string>
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones Nightlies</string>
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones de Nightlies y DroidDeck</string>
<string name="cmgr_nightly_replaced">Falló la descarga: es posible que esta compilación nocturna se haya reemplazado por una más nueva. Actualiza la lista e inténtalo de nuevo.</string>
<string name="cmgr_list_predates_checksums">Esta lista de paquetes es anterior a las sumas de verificación - actualízala e inténtalo de nuevo</string>
<string name="cmgr_checksum_mismatch">La suma de verificación no coincide</string>
<string name="sprefs_upscaler_linear">Lineal</string>
@@ -2053,6 +2066,9 @@
<string name="cloud_last_sync">Última sincronización %1$s</string>
<string name="cloud_never">Aún sin sincronizar</string>
<string name="cloud_done">%1$d archivos</string>
<string name="cloud_up_to_date">Ya está al día</string>
<string name="cloud_signed_out">Inicia sesión en la tienda para sincronizar las partidas</string>
<string name="cloud_not_synced">No se sincronizaron las partidas</string>
<string name="stores_eyebrow_installed">instalado</string>
<string name="stores_eyebrow_library">en tu biblioteca</string>
<string name="stores_eyebrow_store">tienda</string>
@@ -2458,6 +2474,15 @@
<string name="glasses_vr_fov">Campo de visión de SteamVR</string>
<string name="glasses_vr_fov_hint">Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor.</string>
<string name="glasses_playing">Jugando en %1$s</string>
<string name="glasses_phone_recenter">Recentrar</string>
<string name="glasses_phone_steamvr_menu">Menú de SteamVR</string>
<string name="glasses_phone_switch_sticks">Sticks: manos ⇄ juego</string>
<string name="glasses_phone_controllers">Controles: %1$s</string>
<string name="glasses_phone_keyboard">Teclado</string>
<string name="glasses_phone_session_menu">Menú de sesión</string>
<string name="glasses_phone_stop_steamvr">Detener SteamVR</string>
<string name="glasses_phone_brightness">Brillo %1$d / %2$d</string>
<string name="glasses_phone_battery">Batería %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Calibración del seguimiento</string>
<string name="glasses_calibration_hint">Reduce la deriva y alinea la referencia horizontal.</string>
<string name="glasses_calibration_saved_hint">Estas gafas usan la calibración guardada.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Еднократно щракване</string>
<string name="gesture_hold_delay">Закъснение за задържане</string>
<string name="gesture_double_delay">Интервал за двойно докосване</string>
<string name="gesture_scope_game">Прилага се към текущата игра</string>
<string name="gesture_scope_default">Настройки по подразбиране за всички игри</string>
<string name="gesture_reset">Възстановяване на първоначалните настройки на жестовете</string>
<string name="gesture_cancel">Отказ</string>
<string name="gesture_action">Избор на действие</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Колелце на мишката</string>
<string name="gesture_threshold">Праг на жестовете: %1$d пиксела</string>
<string name="gesture_debug">Показване на разпознатия жест</string>
<string name="gesture_editor_subtitle">Управление с мишка и клавиатура, подредено по жестове</string>
<string name="gesture_tab_one">Един пръст</string>
<string name="gesture_tab_two">Два пръста</string>
<string name="gesture_tab_three">Три пръста</string>
<string name="gesture_tab_options">Опции</string>
<string name="gesture_one_hint">Изберете действието при докосване, плъзгане или задържане.</string>
<string name="gesture_two_hint">Настройте преместването на камерата, мащабирането и вторичните щраквания.</string>
<string name="gesture_three_hint">Дръжте допълнителните команди подръка.</string>
<string name="gesture_options_hint">Настройте движението, разпознаването и визуалната обратна връзка.</string>
<string name="gesture_defaults">Стандартни настройки</string>
<string name="gesture_game">Тази игра</string>
<string name="gesture_use_defaults">Използване на подразбиращите се</string>
<string name="gesture_inheriting">Използват се настройките по подразбиране. Редактирането създава настройки за тази игра.</string>
<string name="gesture_save_changes">Запазване на промените</string>
<string name="gesture_close">Затвори</string>
<string name="gesture_action_label">ДЕЙСТВИЕ</string>
<string name="gesture_action_for">За %1$s</string>
<string name="gesture_search_actions">Търсене на действия с мишката или клавиши</string>
<string name="gesture_clear_search">Изчистване на търсенето</string>
<string name="gesture_all_actions">Всички</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Цифрова клавиатура</string>
<string name="gesture_group_symbols">Символи</string>
<string name="gesture_movement">Движение на мишката</string>
<string name="gesture_movement_hint">Директното плъзгане следва мястото на докосване. Относителното следва движението на пръста и е полезно, когато играта центрира мишката.</string>
<string name="gesture_middle_relative_hint">Преместването със средния бутон винаги следва движението на пръста, за да няма скокове на курсора.</string>
<string name="gesture_direct">Директно</string>
<string name="gesture_relative">Относително</string>
<string name="gesture_recognition">Разпознаване на жестове</string>
<string name="gesture_threshold_hint">Изминете това разстояние, преди жест с два или три пръста да стане преместване или мащабиране.</string>
<string name="gesture_feedback">Визуална обратна връзка</string>
<string name="gesture_mouse_behavior">Поведение на бутона на мишката</string>
<string name="gesture_ms">%1$d мс</string>
+28 -3
View File
@@ -504,7 +504,9 @@
<string name="comp_delete_original_title">Изтриване на този оригинал?</string>
<string name="comp_delete_original_body">Този оригинал на %1$s е от по-стара версия на %2$s. Оригиналът на текущата версия винаги се запазва.</string>
<string name="comp_nightlies_count">В Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Обновете, за да видите пакетите в Nightlies.</string>
<string name="comp_channel_stable">Стабилна</string>
<string name="comp_channel_nightly">Нощна</string>
<string name="comp_refresh_to_list">Обновете, за да видите пакетите в Nightlies и DroidDeck.</string>
<string name="comp_nothing_new">Нищо ново от последната проверка.</string>
<string name="comp_import_wcp">Импортиране на .wcp</string>
<string name="comp_restore_original">Възстановяване на оригинала на този Proton</string>
@@ -582,6 +584,16 @@
<string name="drawer_prev_page">Предишна страница</string>
<string name="drawer_next_page">Следваща страница</string>
<string name="drawer_hud">HUD за производителност</string>
<string name="drawer_hud_fps">Честота на кадрите</string>
<string name="drawer_hud_battery">Температура на батерията</string>
<string name="drawer_hud_graph">Графика на производителността</string>
<string name="drawer_hud_color">Цвят</string>
<string name="drawer_hud_color_mint">Мента</string>
<string name="drawer_hud_color_white">Бяло</string>
<string name="drawer_hud_color_sky">Небесно</string>
<string name="drawer_hud_color_amber">Кехлибарено</string>
<string name="drawer_hud_color_violet">Виолетово</string>
<string name="drawer_hud_opacity">Прозрачност</string>
<string name="drawer_scaling">Режим на мащабиране</string>
<string name="drawer_scaling_sharpness">Острота на мащабиране</string>
<string name="drawer_effects">Ефекти на екрана</string>
@@ -1770,7 +1782,8 @@
<string name="cmgr_original_kept">Оригиналът на инсталираната версия е запазен: той е пътят назад.</string>
<string name="cmgr_not_stored">Не е запазен</string>
<string name="cmgr_deleted_original">Изтрит е оригиналът %1$s на %2$s.</string>
<string name="cmgr_nightlies_only">Изтеглянията идват само от версиите Nightlies</string>
<string name="cmgr_nightlies_only">Изтеглянията идват само от версиите на Nightlies и DroidDeck</string>
<string name="cmgr_nightly_replaced">Изтеглянето не бе успешно: тази нощна версия може да е заменена с по-нова. Обновете списъка и опитайте отново.</string>
<string name="cmgr_list_predates_checksums">Този списък с пакети е отпреди въвеждането на контролни суми - обнови го и опитай отново</string>
<string name="cmgr_checksum_mismatch">Несъответствие в контролната сума</string>
@@ -2026,6 +2039,9 @@
<string name="cloud_last_sync">Последна синхронизация %1$s</string>
<string name="cloud_never">Още не е синхронизирано</string>
<string name="cloud_done">%1$d файла</string>
<string name="cloud_up_to_date">Вече е актуално</string>
<string name="cloud_signed_out">Влезте в магазина, за да синхронизирате записите</string>
<string name="cloud_not_synced">Записите не бяха синхронизирани</string>
<string name="stores_eyebrow_installed">инсталирана</string>
<string name="stores_eyebrow_library">в библиотеката ти</string>
<string name="stores_eyebrow_store">магазин</string>
@@ -2329,7 +2345,7 @@
<string name="touch_done">Готово</string>
<string name="touch_save_failed">Сензорното разположение не можа да се запази</string>
<string name="touch_saved">Сензорното разположение е запазено в Steam</string>
<string name="touch_app_id">Приложение %1$d</string>
<string name="touch_app_id">Приложение %1$s</string>
<string name="touch_label_hint">Етикет (незадължително)</string>
<string name="touch_icons_missing">Иконите на Steam не бяха намерени</string>
<plurals name="touch_icons_found">
@@ -2420,6 +2436,15 @@
<string name="glasses_vr_fov">Зрително поле на SteamVR</string>
<string name="glasses_vr_fov_hint">По-ниско приближава SteamVR и запълва очилата; по-високо показва повече около вас.</string>
<string name="glasses_playing">Играете на %1$s</string>
<string name="glasses_phone_recenter">Центриране отново</string>
<string name="glasses_phone_steamvr_menu">Меню на SteamVR</string>
<string name="glasses_phone_switch_sticks">Стикове: ръце ⇄ игра</string>
<string name="glasses_phone_controllers">Контролери: %1$s</string>
<string name="glasses_phone_keyboard">Клавиатура</string>
<string name="glasses_phone_session_menu">Меню на сесията</string>
<string name="glasses_phone_stop_steamvr">Спиране на SteamVR</string>
<string name="glasses_phone_brightness">Яркост %1$d / %2$d</string>
<string name="glasses_phone_battery">Батерия %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Калибриране на проследяването</string>
<string name="glasses_calibration_hint">Намалява дрейфа и подравнява хоризонта.</string>
<string name="glasses_calibration_saved_hint">За тези очила се прилага запазено калибриране.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Jednou kliknout</string>
<string name="gesture_hold_delay">Prodleva podržení</string>
<string name="gesture_double_delay">Interval dvojitého klepnutí</string>
<string name="gesture_scope_game">Platí pro aktuální hru</string>
<string name="gesture_scope_default">Výchozí nastavení pro všechny hry</string>
<string name="gesture_reset">Obnovit původní nastavení gest</string>
<string name="gesture_cancel">Zrušit</string>
<string name="gesture_action">Vybrat akci</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Kolečko myši</string>
<string name="gesture_threshold">Práh gest: %1$d px</string>
<string name="gesture_debug">Zobrazovat rozpoznané gesto</string>
<string name="gesture_editor_subtitle">Ovládání myši a klávesnice uspořádané podle gest</string>
<string name="gesture_tab_one">Jeden prst</string>
<string name="gesture_tab_two">Dva prsty</string>
<string name="gesture_tab_three">Tři prsty</string>
<string name="gesture_tab_options">Možnosti</string>
<string name="gesture_one_hint">Vyberte akci pro klepnutí, tažení nebo podržení.</string>
<string name="gesture_two_hint">Nastavte posouvání kamery, přiblížení a vedlejší kliknutí.</string>
<string name="gesture_three_hint">Mějte další zkratky na dosah.</string>
<string name="gesture_options_hint">Upravte pohyb, rozpoznávání a vizuální odezvu.</string>
<string name="gesture_defaults">Výchozí</string>
<string name="gesture_game">Tato hra</string>
<string name="gesture_use_defaults">Použít výchozí</string>
<string name="gesture_inheriting">Používá se výchozí nastavení. Úpravou vznikne nastavení pro tuto hru.</string>
<string name="gesture_save_changes">Uložit změny</string>
<string name="gesture_close">Zavřít</string>
<string name="gesture_action_label">AKCE</string>
<string name="gesture_action_for">Pro %1$s</string>
<string name="gesture_search_actions">Hledat akce myši nebo klávesy</string>
<string name="gesture_clear_search">Vymazat hledání</string>
<string name="gesture_all_actions">Vše</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Numerická klávesnice</string>
<string name="gesture_group_symbols">Symboly</string>
<string name="gesture_movement">Pohyb myši</string>
<string name="gesture_movement_hint">Přímé tažení sleduje polohu dotyku. Relativní tažení sleduje pohyb prstu a hodí se, když hra vrací myš do středu.</string>
<string name="gesture_middle_relative_hint">Posouvání prostředním tlačítkem vždy sleduje pohyb prstu, aby kurzor nepřeskakoval.</string>
<string name="gesture_direct">Přímé</string>
<string name="gesture_relative">Relativní</string>
<string name="gesture_recognition">Rozpoznávání gest</string>
<string name="gesture_threshold_hint">Po překonání této vzdálenosti se gesto dvěma či třemi prsty změní na posouvání nebo přiblížení.</string>
<string name="gesture_feedback">Vizuální odezva</string>
<string name="gesture_mouse_behavior">Chování tlačítka myši</string>
<string name="gesture_ms">%1$d ms</string>
+28 -3
View File
@@ -508,7 +508,9 @@
<string name="comp_delete_original_title">Smazat tento originál?</string>
<string name="comp_delete_original_body">Tento originál %1$s je z dřívějšího sestavení %2$s. Originál aktuálního sestavení je vždy zachován.</string>
<string name="comp_nightlies_count">Na Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Obnovte pro zobrazení balíčků na Nightlies.</string>
<string name="comp_channel_stable">Stabilní</string>
<string name="comp_channel_nightly">Noční</string>
<string name="comp_refresh_to_list">Obnovte pro zobrazení balíčků na Nightlies a DroidDeck.</string>
<string name="comp_nothing_new">Od poslední kontroly nic nového.</string>
<string name="comp_import_wcp">Importovat .wcp</string>
<string name="comp_restore_original">Obnovit originál tohoto Protonu</string>
@@ -586,6 +588,16 @@
<string name="drawer_prev_page">Předchozí stránka</string>
<string name="drawer_next_page">Další stránka</string>
<string name="drawer_hud">HUD výkonu</string>
<string name="drawer_hud_fps">Snímková frekvence</string>
<string name="drawer_hud_battery">Teplota baterie</string>
<string name="drawer_hud_graph">Graf výkonu</string>
<string name="drawer_hud_color">Barva</string>
<string name="drawer_hud_color_mint">Máta</string>
<string name="drawer_hud_color_white">Bílá</string>
<string name="drawer_hud_color_sky">Nebeská</string>
<string name="drawer_hud_color_amber">Ambrová</string>
<string name="drawer_hud_color_violet">Fialová</string>
<string name="drawer_hud_opacity">Průhlednost</string>
<string name="drawer_scaling">Režim škálování</string>
<string name="drawer_scaling_sharpness">Ostrost škálování</string>
<string name="drawer_effects">Efekty obrazovky</string>
@@ -1812,7 +1824,8 @@
<string name="cmgr_original_kept">Původní verze nainstalovaného sestavení je zachována: je to cesta zpět.</string>
<string name="cmgr_not_stored">Není uloženo</string>
<string name="cmgr_deleted_original">Odstraněna původní verze %2$s z %1$s.</string>
<string name="cmgr_nightlies_only">Stahování pochází jen z vydání Nightlies</string>
<string name="cmgr_nightlies_only">Stahování pochází jen z vydání Nightlies a DroidDeck</string>
<string name="cmgr_nightly_replaced">Stažení selhalo: tento noční build mohl být nahrazen novějším. Obnovte seznam a zkuste to znovu.</string>
<string name="cmgr_list_predates_checksums">Tento seznam balíčků předchází kontrolním součtům - obnovte ho a zkuste to znovu</string>
<string name="cmgr_checksum_mismatch">Neshoda kontrolního součtu</string>
@@ -2068,6 +2081,9 @@
<string name="cloud_last_sync">Poslední synchronizace %1$s</string>
<string name="cloud_never">Ještě nesynchronizováno</string>
<string name="cloud_done">%1$d souborů</string>
<string name="cloud_up_to_date">Už je aktuální</string>
<string name="cloud_signed_out">Pro synchronizaci uložených pozic se přihlaste do obchodu</string>
<string name="cloud_not_synced">Uložené pozice nebyly synchronizovány</string>
<string name="stores_eyebrow_installed">nainstalováno</string>
<string name="stores_eyebrow_library">ve vaší knihovně</string>
<string name="stores_eyebrow_store">obchod</string>
@@ -2375,7 +2391,7 @@
<string name="touch_done">Hotovo</string>
<string name="touch_save_failed">Dotykové rozložení se nepodařilo uložit</string>
<string name="touch_saved">Dotykové rozložení uloženo ve Steamu</string>
<string name="touch_app_id">Aplikace %1$d</string>
<string name="touch_app_id">Aplikace %1$s</string>
<string name="touch_label_hint">Popisek (nepovinné)</string>
<string name="touch_icons_missing">Ikony Steamu nebyly nalezeny</string>
<plurals name="touch_icons_found">
@@ -2470,6 +2486,15 @@
<string name="glasses_vr_fov">Zorné pole SteamVR</string>
<string name="glasses_vr_fov_hint">Nižší hodnota přiblíží SteamVR a vyplní brýle; vyšší ukáže víc z okolí.</string>
<string name="glasses_playing">Hraje se na %1$s</string>
<string name="glasses_phone_recenter">Znovu vycentrovat</string>
<string name="glasses_phone_steamvr_menu">Nabídka SteamVR</string>
<string name="glasses_phone_switch_sticks">Páčky: ruce ⇄ hra</string>
<string name="glasses_phone_controllers">Ovladače: %1$s</string>
<string name="glasses_phone_keyboard">Klávesnice</string>
<string name="glasses_phone_session_menu">Nabídka relace</string>
<string name="glasses_phone_stop_steamvr">Zastavit SteamVR</string>
<string name="glasses_phone_brightness">Jas %1$d / %2$d</string>
<string name="glasses_phone_battery">Baterie %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Kalibrace sledování</string>
<string name="glasses_calibration_hint">Sníží drift a vyrovná vodorovnou referenci.</string>
<string name="glasses_calibration_saved_hint">Pro tyto brýle se používá uložená kalibrace.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Klik én gang</string>
<string name="gesture_hold_delay">Forsinkelse før hold</string>
<string name="gesture_double_delay">Interval for dobbelttryk</string>
<string name="gesture_scope_game">Gælder det aktuelle spil</string>
<string name="gesture_scope_default">Standardindstillinger for alle spil</string>
<string name="gesture_reset">Gendan de oprindelige bevægelsesindstillinger</string>
<string name="gesture_cancel">Annuller</string>
<string name="gesture_action">Vælg handling</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Musehjul</string>
<string name="gesture_threshold">Bevægelsestærskel: %1$d px</string>
<string name="gesture_debug">Vis genkendt bevægelse</string>
<string name="gesture_editor_subtitle">Muse- og tastaturstyring organiseret efter bevægelse</string>
<string name="gesture_tab_one">Én finger</string>
<string name="gesture_tab_two">To fingre</string>
<string name="gesture_tab_three">Tre fingre</string>
<string name="gesture_tab_options">Indstillinger</string>
<string name="gesture_one_hint">Vælg, hvad et tryk, træk eller hold skal gøre.</string>
<string name="gesture_two_hint">Indstil kamerapanorering, zoom og sekundære klik.</string>
<string name="gesture_three_hint">Hav flere genveje inden for rækkevidde.</string>
<string name="gesture_options_hint">Juster bevægelse, genkendelse og visuel feedback.</string>
<string name="gesture_defaults">Standarder</string>
<string name="gesture_game">Dette spil</string>
<string name="gesture_use_defaults">Brug standardindstillinger</string>
<string name="gesture_inheriting">Bruger standardindstillingerne. Redigering opretter særlige indstillinger for spillet.</string>
<string name="gesture_save_changes">Gem ændringer</string>
<string name="gesture_close">Luk</string>
<string name="gesture_action_label">HANDLING</string>
<string name="gesture_action_for">For %1$s</string>
<string name="gesture_search_actions">Søg efter musehandlinger eller tastaturtaster</string>
<string name="gesture_clear_search">Ryd søgning</string>
<string name="gesture_all_actions">Alle</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Numerisk tastatur</string>
<string name="gesture_group_symbols">Symboler</string>
<string name="gesture_movement">Musebevægelse</string>
<string name="gesture_movement_hint">Direkte træk følger berøringspositionen. Relative træk følger fingerbevægelsen og er nyttige, når et spil centrerer musen.</string>
<string name="gesture_middle_relative_hint">Panorering med midterste museknap følger altid fingerbevægelsen for at undgå markørspring.</string>
<string name="gesture_direct">Direkte</string>
<string name="gesture_relative">Relativ</string>
<string name="gesture_recognition">Genkendelse af bevægelser</string>
<string name="gesture_threshold_hint">Bevæg fingrene så langt, før en bevægelse med to eller tre fingre bliver til panorering eller zoom.</string>
<string name="gesture_feedback">Visuel feedback</string>
<string name="gesture_mouse_behavior">Museknappens funktion</string>
<string name="gesture_ms">%1$d ms</string>
+28 -3
View File
@@ -504,7 +504,9 @@
<string name="comp_delete_original_title">Slet denne original?</string>
<string name="comp_delete_original_body">Denne original af %1$s er fra en tidligere build af %2$s. Den aktuelle builds original bevares altid.</string>
<string name="comp_nightlies_count">På Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Opdater for at se pakker på Nightlies.</string>
<string name="comp_channel_stable">Stabil</string>
<string name="comp_channel_nightly">Nightly</string>
<string name="comp_refresh_to_list">Opdater for at se pakker på Nightlies og DroidDeck.</string>
<string name="comp_nothing_new">Intet nyt siden sidste kontrol.</string>
<string name="comp_import_wcp">Importer .wcp</string>
<string name="comp_restore_original">Gendan denne Protons original</string>
@@ -582,6 +584,16 @@
<string name="drawer_prev_page">Forrige side</string>
<string name="drawer_next_page">Næste side</string>
<string name="drawer_hud">Ydelses-HUD</string>
<string name="drawer_hud_fps">Billedhastighed</string>
<string name="drawer_hud_battery">Batteritemperatur</string>
<string name="drawer_hud_graph">Ydelsesgraf</string>
<string name="drawer_hud_color">Farve</string>
<string name="drawer_hud_color_mint">Mint</string>
<string name="drawer_hud_color_white">Hvid</string>
<string name="drawer_hud_color_sky">Himmelblå</string>
<string name="drawer_hud_color_amber">Ravgul</string>
<string name="drawer_hud_color_violet">Violet</string>
<string name="drawer_hud_opacity">Gennemsigtighed</string>
<string name="drawer_scaling">Skaleringstilstand</string>
<string name="drawer_scaling_sharpness">Skaleringsskarphed</string>
<string name="drawer_effects">Skærmeffekter</string>
@@ -1770,7 +1782,8 @@
<string name="cmgr_original_kept">Originalen af den installerede version beholdes: den er vejen tilbage.</string>
<string name="cmgr_not_stored">Ikke gemt</string>
<string name="cmgr_deleted_original">Slettede %1$s-originalen af %2$s.</string>
<string name="cmgr_nightlies_only">Downloads kommer kun fra Nightlies-udgivelserne</string>
<string name="cmgr_nightlies_only">Downloads kommer kun fra Nightlies- og DroidDeck-udgivelserne</string>
<string name="cmgr_nightly_replaced">Download mislykkedes: denne nightly kan være erstattet af en nyere build. Opdater listen, og prøv igen.</string>
<string name="cmgr_list_predates_checksums">Denne pakkeliste er ældre end tjeksummer - opdater den, og forsøg igen</string>
<string name="cmgr_checksum_mismatch">Tjeksum stemmer ikke</string>
@@ -2026,6 +2039,9 @@
<string name="cloud_last_sync">Senest synkroniseret %1$s</string>
<string name="cloud_never">Ikke synkroniseret endnu</string>
<string name="cloud_done">%1$d filer</string>
<string name="cloud_up_to_date">Allerede opdateret</string>
<string name="cloud_signed_out">Log ind i butikken for at synkronisere gemte spil</string>
<string name="cloud_not_synced">Gemte spil blev ikke synkroniseret</string>
<string name="stores_eyebrow_installed">installeret</string>
<string name="stores_eyebrow_library">i dit bibliotek</string>
<string name="stores_eyebrow_store">butik</string>
@@ -2329,7 +2345,7 @@
<string name="touch_done">Udført</string>
<string name="touch_save_failed">Berøringslayoutet kunne ikke gemmes</string>
<string name="touch_saved">Berøringslayout gemt i Steam</string>
<string name="touch_app_id">App %1$d</string>
<string name="touch_app_id">App %1$s</string>
<string name="touch_label_hint">Etiket (valgfrit)</string>
<string name="touch_icons_missing">Steams ikoner blev ikke fundet</string>
<plurals name="touch_icons_found">
@@ -2420,6 +2436,15 @@
<string name="glasses_vr_fov">SteamVR-synsfelt</string>
<string name="glasses_vr_fov_hint">Lavere bringer SteamVR tættere på og fylder brillen; højere viser mere omkring dig.</string>
<string name="glasses_playing">Spiller på %1$s</string>
<string name="glasses_phone_recenter">Centrér igen</string>
<string name="glasses_phone_steamvr_menu">SteamVR-menu</string>
<string name="glasses_phone_switch_sticks">Pinde: hænder ⇄ spil</string>
<string name="glasses_phone_controllers">Controllere: %1$s</string>
<string name="glasses_phone_keyboard">Tastatur</string>
<string name="glasses_phone_session_menu">Sessionsmenu</string>
<string name="glasses_phone_stop_steamvr">Stop SteamVR</string>
<string name="glasses_phone_brightness">Lysstyrke %1$d / %2$d</string>
<string name="glasses_phone_battery">Batteri %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Sporingskalibrering</string>
<string name="glasses_calibration_hint">Mindsker drift og justerer den vandrette reference.</string>
<string name="glasses_calibration_saved_hint">Gemt kalibrering anvendes på denne brille.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Einmal klicken</string>
<string name="gesture_hold_delay">Halteverzögerung</string>
<string name="gesture_double_delay">Doppeltipp-Intervall</string>
<string name="gesture_scope_game">Gilt für das aktuelle Spiel</string>
<string name="gesture_scope_default">Standardeinstellungen für alle Spiele</string>
<string name="gesture_reset">Ursprüngliche Gesteneinstellungen wiederherstellen</string>
<string name="gesture_cancel">Abbrechen</string>
<string name="gesture_action">Aktion auswählen</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Mausrad</string>
<string name="gesture_threshold">Gestenschwelle: %1$d px</string>
<string name="gesture_debug">Erkannte Geste anzeigen</string>
<string name="gesture_editor_subtitle">Maus- und Tastatursteuerung nach Gesten geordnet</string>
<string name="gesture_tab_one">Ein Finger</string>
<string name="gesture_tab_two">Zwei Finger</string>
<string name="gesture_tab_three">Drei Finger</string>
<string name="gesture_tab_options">Optionen</string>
<string name="gesture_one_hint">Wähle die Aktion für Tippen, Ziehen oder Halten.</string>
<string name="gesture_two_hint">Kameraschwenks, Zoom und sekundäre Klicks anpassen.</string>
<string name="gesture_three_hint">Zusätzliche Kurzbefehle griffbereit halten.</string>
<string name="gesture_options_hint">Bewegung, Erkennung und visuelle Rückmeldung anpassen.</string>
<string name="gesture_defaults">Standardwerte</string>
<string name="gesture_game">Dieses Spiel</string>
<string name="gesture_use_defaults">Standardeinstellungen verwenden</string>
<string name="gesture_inheriting">Standardeinstellungen werden verwendet. Änderungen erstellen eine Spielanpassung.</string>
<string name="gesture_save_changes">Änderungen speichern</string>
<string name="gesture_close">Schließen</string>
<string name="gesture_action_label">AKTION</string>
<string name="gesture_action_for">Für %1$s</string>
<string name="gesture_search_actions">Mausaktionen oder Tastaturtasten suchen</string>
<string name="gesture_clear_search">Suche leeren</string>
<string name="gesture_all_actions">Alle</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Ziffernblock</string>
<string name="gesture_group_symbols">Symbole</string>
<string name="gesture_movement">Mausbewegung</string>
<string name="gesture_movement_hint">Direktes Ziehen folgt der Berührungsposition. Relatives Ziehen folgt der Fingerbewegung und eignet sich für Spiele, die die Maus zentrieren.</string>
<string name="gesture_middle_relative_hint">Schwenks mit mittlerer Maustaste folgen immer der Fingerbewegung, um Zeigersprünge zu vermeiden.</string>
<string name="gesture_direct">Direkt</string>
<string name="gesture_relative">Relativ</string>
<string name="gesture_recognition">Gestenerkennung</string>
<string name="gesture_threshold_hint">Bewege die Finger um diese Strecke, bevor eine Zwei- oder Dreifingergeste als Schwenken oder Zoomen gilt.</string>
<string name="gesture_feedback">Visuelle Rückmeldung</string>
<string name="gesture_mouse_behavior">Verhalten der Maustaste</string>
<string name="gesture_ms">%1$d ms</string>
+28 -3
View File
@@ -501,7 +501,9 @@
<string name="comp_delete_original_title">Dieses Original löschen?</string>
<string name="comp_delete_original_body">Dieses %1$s-Original stammt aus einem früheren Build von %2$s. Das Original des aktuellen Builds wird immer behalten.</string>
<string name="comp_nightlies_count">In den Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Aktualisiere, um die Pakete in den Nightlies aufzulisten.</string>
<string name="comp_channel_stable">Stabil</string>
<string name="comp_channel_nightly">Nightly</string>
<string name="comp_refresh_to_list">Aktualisiere, um die Pakete in den Nightlies und bei DroidDeck aufzulisten.</string>
<string name="comp_nothing_new">Nichts Neues seit der letzten Prüfung.</string>
<string name="comp_import_wcp">.wcp importieren</string>
<string name="comp_restore_original">Original dieses Protons wiederherstellen</string>
@@ -579,6 +581,16 @@
<string name="drawer_prev_page">Vorherige Seite</string>
<string name="drawer_next_page">Nächste Seite</string>
<string name="drawer_hud">Leistungs-HUD</string>
<string name="drawer_hud_fps">Bildrate</string>
<string name="drawer_hud_battery">Akkutemperatur</string>
<string name="drawer_hud_graph">Leistungsdiagramm</string>
<string name="drawer_hud_color">Farbe</string>
<string name="drawer_hud_color_mint">Minze</string>
<string name="drawer_hud_color_white">Weiß</string>
<string name="drawer_hud_color_sky">Himmelblau</string>
<string name="drawer_hud_color_amber">Bernstein</string>
<string name="drawer_hud_color_violet">Violett</string>
<string name="drawer_hud_opacity">Deckkraft</string>
<string name="drawer_scaling">Skalierungsmodus</string>
<string name="drawer_scaling_sharpness">Skalierungsschärfe</string>
<string name="drawer_effects">Bildschirmeffekte</string>
@@ -1759,7 +1771,8 @@
<string name="cmgr_original_kept">Das Original des installierten Builds bleibt erhalten: Es ist der Weg zurück.</string>
<string name="cmgr_not_stored">Nicht gespeichert</string>
<string name="cmgr_deleted_original">Das %1$s-Original von %2$s wurde gelöscht.</string>
<string name="cmgr_nightlies_only">Downloads kommen nur aus den Nightlies-Releases</string>
<string name="cmgr_nightlies_only">Downloads kommen nur aus den Nightlies- und DroidDeck-Releases</string>
<string name="cmgr_nightly_replaced">Download fehlgeschlagen: Dieser Nightly-Build wurde vielleicht durch einen neueren ersetzt. Aktualisiere die Liste und versuche es erneut.</string>
<string name="cmgr_list_predates_checksums">Diese Paketliste stammt aus der Zeit vor Prüfsummen - aktualisiere sie und versuche es erneut</string>
<string name="cmgr_checksum_mismatch">Prüfsumme stimmt nicht überein</string>
@@ -2015,6 +2028,9 @@
<string name="cloud_last_sync">Zuletzt synchronisiert %1$s</string>
<string name="cloud_never">Noch nicht synchronisiert</string>
<string name="cloud_done">%1$d Dateien</string>
<string name="cloud_up_to_date">Bereits aktuell</string>
<string name="cloud_signed_out">Im Store anmelden, um Spielstände zu synchronisieren</string>
<string name="cloud_not_synced">Spielstände wurden nicht synchronisiert</string>
<string name="stores_eyebrow_installed">installiert</string>
<string name="stores_eyebrow_library">in deiner Bibliothek</string>
<string name="stores_eyebrow_store">Store</string>
@@ -2315,7 +2331,7 @@
<string name="touch_done">Fertig</string>
<string name="touch_save_failed">Das Touch-Layout konnte nicht gespeichert werden</string>
<string name="touch_saved">Touch-Layout in Steam gespeichert</string>
<string name="touch_app_id">App %1$d</string>
<string name="touch_app_id">App %1$s</string>
<string name="touch_label_hint">Bezeichnung (optional)</string>
<string name="touch_icons_missing">Steams Symbole wurden nicht gefunden</string>
<plurals name="touch_icons_found">
@@ -2406,6 +2422,15 @@
<string name="glasses_vr_fov">SteamVR-Sichtfeld</string>
<string name="glasses_vr_fov_hint">Niedriger holt SteamVR näher heran und füllt die Brille; höher zeigt mehr von deiner Umgebung.</string>
<string name="glasses_playing">Spiel läuft auf %1$s</string>
<string name="glasses_phone_recenter">Neu zentrieren</string>
<string name="glasses_phone_steamvr_menu">SteamVR-Menü</string>
<string name="glasses_phone_switch_sticks">Sticks: Hände ⇄ Spiel</string>
<string name="glasses_phone_controllers">Controller: %1$s</string>
<string name="glasses_phone_keyboard">Tastatur</string>
<string name="glasses_phone_session_menu">Sitzungsmenü</string>
<string name="glasses_phone_stop_steamvr">SteamVR beenden</string>
<string name="glasses_phone_brightness">Helligkeit %1$d / %2$d</string>
<string name="glasses_phone_battery">Akku %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Tracking-Kalibrierung</string>
<string name="glasses_calibration_hint">Verringert Drift und richtet die waagerechte Referenz aus.</string>
<string name="glasses_calibration_saved_hint">Für diese Brille wird eine gespeicherte Kalibrierung verwendet.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Ένα κλικ</string>
<string name="gesture_hold_delay">Καθυστέρηση κρατήματος</string>
<string name="gesture_double_delay">Διάστημα διπλού πατήματος</string>
<string name="gesture_scope_game">Ισχύει για το τρέχον παιχνίδι</string>
<string name="gesture_scope_default">Προεπιλεγμένες ρυθμίσεις για όλα τα παιχνίδια</string>
<string name="gesture_reset">Επαναφορά αρχικών ρυθμίσεων χειρονομιών</string>
<string name="gesture_cancel">Ακύρωση</string>
<string name="gesture_action">Επιλογή ενέργειας</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Ροδέλα ποντικιού</string>
<string name="gesture_threshold">Όριο χειρονομιών: %1$d px</string>
<string name="gesture_debug">Εμφάνιση αναγνωρισμένης χειρονομίας</string>
<string name="gesture_editor_subtitle">Χειρισμοί ποντικιού και πληκτρολογίου ανά χειρονομία</string>
<string name="gesture_tab_one">Ένα δάχτυλο</string>
<string name="gesture_tab_two">Δύο δάχτυλα</string>
<string name="gesture_tab_three">Τρία δάχτυλα</string>
<string name="gesture_tab_options">Επιλογές</string>
<string name="gesture_one_hint">Επιλέξτε την ενέργεια για πάτημα, σύρσιμο ή κράτημα.</string>
<string name="gesture_two_hint">Ρυθμίστε τη μετατόπιση της κάμερας, τη μεγέθυνση και τα δευτερεύοντα κλικ.</string>
<string name="gesture_three_hint">Κρατήστε επιπλέον συντομεύσεις κοντά σας.</string>
<string name="gesture_options_hint">Ρυθμίστε την κίνηση, την αναγνώριση και την οπτική ανάδραση.</string>
<string name="gesture_defaults">Προεπιλογές</string>
<string name="gesture_game">Αυτό το παιχνίδι</string>
<string name="gesture_use_defaults">Χρήση προεπιλογών</string>
<string name="gesture_inheriting">Χρησιμοποιούνται οι προεπιλογές. Η επεξεργασία δημιουργεί ρυθμίσεις για αυτό το παιχνίδι.</string>
<string name="gesture_save_changes">Αποθήκευση αλλαγών</string>
<string name="gesture_close">Κλείσιμο</string>
<string name="gesture_action_label">ΕΝΕΡΓΕΙΑ</string>
<string name="gesture_action_for">Για %1$s</string>
<string name="gesture_search_actions">Αναζήτηση ενεργειών ποντικιού ή πλήκτρων</string>
<string name="gesture_clear_search">Εκκαθάριση αναζήτησης</string>
<string name="gesture_all_actions">Όλα</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Αριθμητικό πληκτρολόγιο</string>
<string name="gesture_group_symbols">Σύμβολα</string>
<string name="gesture_movement">Κίνηση ποντικιού</string>
<string name="gesture_movement_hint">Το άμεσο σύρσιμο ακολουθεί τη θέση αφής. Το σχετικό σύρσιμο ακολουθεί την κίνηση του δαχτύλου, χρήσιμο όταν το παιχνίδι επαναφέρει το ποντίκι στο κέντρο.</string>
<string name="gesture_middle_relative_hint">Η μετατόπιση με το μεσαίο κουμπί ακολουθεί πάντα την κίνηση του δαχτύλου για να αποφεύγονται άλματα του δείκτη.</string>
<string name="gesture_direct">Απευθείας</string>
<string name="gesture_relative">Σχετική</string>
<string name="gesture_recognition">Αναγνώριση χειρονομιών</string>
<string name="gesture_threshold_hint">Κινηθείτε κατά αυτή την απόσταση προτού μια χειρονομία με δύο ή τρία δάχτυλα γίνει μετατόπιση ή μεγέθυνση.</string>
<string name="gesture_feedback">Οπτική ανάδραση</string>
<string name="gesture_mouse_behavior">Συμπεριφορά κουμπιού ποντικιού</string>
<string name="gesture_ms">%1$d ms</string>
+28 -3
View File
@@ -504,7 +504,9 @@
<string name="comp_delete_original_title">Διαγραφή αυτού του πρωτοτύπου;</string>
<string name="comp_delete_original_body">Αυτό το πρωτότυπο %1$s είναι από προηγούμενο build του %2$s. Το πρωτότυπο του τρέχοντος build διατηρείται πάντα.</string>
<string name="comp_nightlies_count">Στα Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Ανανεώστε για να εμφανιστούν τα πακέτα στα Nightlies.</string>
<string name="comp_channel_stable">Σταθερή</string>
<string name="comp_channel_nightly">Νυχτερινή</string>
<string name="comp_refresh_to_list">Ανανεώστε για να εμφανιστούν τα πακέτα στα Nightlies και στο DroidDeck.</string>
<string name="comp_nothing_new">Τίποτα νέο από τον τελευταίο έλεγχο.</string>
<string name="comp_import_wcp">Εισαγωγή .wcp</string>
<string name="comp_restore_original">Επαναφορά του πρωτοτύπου αυτού του Proton</string>
@@ -582,6 +584,16 @@
<string name="drawer_prev_page">Προηγούμενη σελίδα</string>
<string name="drawer_next_page">Επόμενη σελίδα</string>
<string name="drawer_hud">HUD απόδοσης</string>
<string name="drawer_hud_fps">Ρυθμός καρέ</string>
<string name="drawer_hud_battery">Θερμοκρασία μπαταρίας</string>
<string name="drawer_hud_graph">Γράφημα απόδοσης</string>
<string name="drawer_hud_color">Χρώμα</string>
<string name="drawer_hud_color_mint">Μέντα</string>
<string name="drawer_hud_color_white">Λευκό</string>
<string name="drawer_hud_color_sky">Γαλάζιο</string>
<string name="drawer_hud_color_amber">Κεχριμπαρί</string>
<string name="drawer_hud_color_violet">Βιολετί</string>
<string name="drawer_hud_opacity">Διαφάνεια</string>
<string name="drawer_scaling">Λειτουργία κλιμάκωσης</string>
<string name="drawer_scaling_sharpness">Οξύτητα κλιμάκωσης</string>
<string name="drawer_effects">Εφέ οθόνης</string>
@@ -1770,7 +1782,8 @@
<string name="cmgr_original_kept">Το αρχικό της εγκατεστημένης έκδοσης διατηρείται: είναι ο δρόμος της επιστροφής.</string>
<string name="cmgr_not_stored">Δεν είναι αποθηκευμένο</string>
<string name="cmgr_deleted_original">Διαγράφηκε το αρχικό %1$s του %2$s.</string>
<string name="cmgr_nightlies_only">Οι λήψεις προέρχονται μόνο από τις κυκλοφορίες Nightlies</string>
<string name="cmgr_nightlies_only">Οι λήψεις προέρχονται μόνο από τις κυκλοφορίες Nightlies και DroidDeck</string>
<string name="cmgr_nightly_replaced">Η λήψη απέτυχε: αυτή η νυχτερινή έκδοση ίσως αντικαταστάθηκε από νεότερη. Ανανεώστε τη λίστα και δοκιμάστε ξανά.</string>
<string name="cmgr_list_predates_checksums">Αυτή η λίστα πακέτων προηγείται των αθροισμάτων ελέγχου - ανανεώστε την και δοκιμάστε ξανά</string>
<string name="cmgr_checksum_mismatch">Αναντιστοιχία αθροίσματος ελέγχου</string>
@@ -2026,6 +2039,9 @@
<string name="cloud_last_sync">Τελευταίος συγχρονισμός %1$s</string>
<string name="cloud_never">Δεν έχει συγχρονιστεί ακόμα</string>
<string name="cloud_done">%1$d αρχεία</string>
<string name="cloud_up_to_date">Ήδη ενημερωμένο</string>
<string name="cloud_signed_out">Συνδεθείτε στο κατάστημα για συγχρονισμό αποθηκεύσεων</string>
<string name="cloud_not_synced">Οι αποθηκεύσεις δεν συγχρονίστηκαν</string>
<string name="stores_eyebrow_installed">εγκατεστημένο</string>
<string name="stores_eyebrow_library">στη βιβλιοθήκη σας</string>
<string name="stores_eyebrow_store">κατάστημα</string>
@@ -2329,7 +2345,7 @@
<string name="touch_done">Τέλος</string>
<string name="touch_save_failed">Δεν ήταν δυνατή η αποθήκευση της διάταξης αφής</string>
<string name="touch_saved">Η διάταξη αφής αποθηκεύτηκε στο Steam</string>
<string name="touch_app_id">Εφαρμογή %1$d</string>
<string name="touch_app_id">Εφαρμογή %1$s</string>
<string name="touch_label_hint">Ετικέτα (προαιρετικό)</string>
<string name="touch_icons_missing">Τα εικονίδια του Steam δεν βρέθηκαν</string>
<plurals name="touch_icons_found">
@@ -2420,6 +2436,15 @@
<string name="glasses_vr_fov">Οπτικό πεδίο SteamVR</string>
<string name="glasses_vr_fov_hint">Χαμηλότερο: το SteamVR έρχεται πιο κοντά και γεμίζει τα γυαλιά· υψηλότερο: βλέπετε περισσότερα γύρω σας.</string>
<string name="glasses_playing">Παίζετε στα %1$s</string>
<string name="glasses_phone_recenter">Επανακεντράρισμα</string>
<string name="glasses_phone_steamvr_menu">Μενού SteamVR</string>
<string name="glasses_phone_switch_sticks">Μοχλοί: χέρια ⇄ παιχνίδι</string>
<string name="glasses_phone_controllers">Χειριστήρια: %1$s</string>
<string name="glasses_phone_keyboard">Πληκτρολόγιο</string>
<string name="glasses_phone_session_menu">Μενού συνεδρίας</string>
<string name="glasses_phone_stop_steamvr">Διακοπή SteamVR</string>
<string name="glasses_phone_brightness">Φωτεινότητα %1$d / %2$d</string>
<string name="glasses_phone_battery">Μπαταρία %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Βαθμονόμηση παρακολούθησης</string>
<string name="glasses_calibration_hint">Μειώνει την ολίσθηση και ευθυγραμμίζει την οριζόντια αναφορά.</string>
<string name="glasses_calibration_saved_hint">Σε αυτά τα γυαλιά εφαρμόζεται αποθηκευμένη βαθμονόμηση.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Hacer clic una vez</string>
<string name="gesture_hold_delay">Tiempo de pulsación sostenida</string>
<string name="gesture_double_delay">Intervalo de doble toque</string>
<string name="gesture_scope_game">Se aplica al juego actual</string>
<string name="gesture_scope_default">Ajustes predeterminados para todos los juegos</string>
<string name="gesture_reset">Restablecer los ajustes originales de gestos</string>
<string name="gesture_cancel">Cancelar</string>
<string name="gesture_action">Elegir acción</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Rueda del ratón</string>
<string name="gesture_threshold">Umbral de gestos: %1$d px</string>
<string name="gesture_debug">Mostrar gesto reconocido</string>
<string name="gesture_editor_subtitle">Controles de ratón y teclado organizados por gesto</string>
<string name="gesture_tab_one">Un dedo</string>
<string name="gesture_tab_two">Dos dedos</string>
<string name="gesture_tab_three">Tres dedos</string>
<string name="gesture_tab_options">Opciones</string>
<string name="gesture_one_hint">Elige qué debe hacer un toque, arrastre o pulsación sostenida.</string>
<string name="gesture_two_hint">Configura el movimiento de cámara, el zoom y los clics secundarios.</string>
<string name="gesture_three_hint">Ten más accesos directos al alcance de la mano.</string>
<string name="gesture_options_hint">Ajusta el movimiento, el reconocimiento y la respuesta visual.</string>
<string name="gesture_defaults">Predeterminados</string>
<string name="gesture_game">Este juego</string>
<string name="gesture_use_defaults">Usar valores predeterminados</string>
<string name="gesture_inheriting">Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego.</string>
<string name="gesture_save_changes">Guardar cambios</string>
<string name="gesture_close">Cerrar</string>
<string name="gesture_action_label">ACCIÓN</string>
<string name="gesture_action_for">Para %1$s</string>
<string name="gesture_search_actions">Buscar acciones de ratón o teclas del teclado</string>
<string name="gesture_clear_search">Borrar búsqueda</string>
<string name="gesture_all_actions">Todas</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Teclado numérico</string>
<string name="gesture_group_symbols">Símbolos</string>
<string name="gesture_movement">Movimiento del ratón</string>
<string name="gesture_movement_hint">El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón.</string>
<string name="gesture_middle_relative_hint">El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor.</string>
<string name="gesture_direct">Directo</string>
<string name="gesture_relative">Relativo</string>
<string name="gesture_recognition">Reconocimiento de gestos</string>
<string name="gesture_threshold_hint">Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom.</string>
<string name="gesture_feedback">Respuesta visual</string>
<string name="gesture_mouse_behavior">Comportamiento del botón del ratón</string>
<string name="gesture_ms">%1$d ms</string>
+30 -3
View File
@@ -307,6 +307,8 @@
<string name="mode_client">Cliente</string>
<string name="mode_mangoapp">Superposición de rendimiento</string>
<string name="mode_mangoapp_hint">Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión.</string>
<string name="mode_return_after_game">Volver a DroidDeck al cerrar un juego</string>
<string name="mode_return_after_game_hint">Cuando se cierra un juego que iniciaste desde DroidDeck u otro lanzador, Steam se cierra correctamente (primero termina de sincronizar las partidas en la nube) y vuelves allí. Desactivado: te quedas en Steam.</string>
<string name="mode_steam_repair">Reparar el cliente de Steam</string>
<string name="mode_steam_repair_hint">Vuelve a descargar el cliente de Steam la próxima vez que se inicie Steam. Úsalo si Steam se queda en la pantalla de carga. Se conservan los juegos, la sesión iniciada y los ajustes.</string>
<string name="mode_steam_repair_queued">El cliente de Steam se volverá a descargar la próxima vez que se inicie Steam.</string>
@@ -503,7 +505,9 @@
<string name="comp_delete_original_title">¿Eliminar este original?</string>
<string name="comp_delete_original_body">Este original de %1$s es de una versión anterior de %2$s. El original de la versión actual siempre se conserva.</string>
<string name="comp_nightlies_count">En las Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de las Nightlies.</string>
<string name="comp_channel_stable">Estable</string>
<string name="comp_channel_nightly">Nocturna</string>
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de Nightlies y DroidDeck.</string>
<string name="comp_nothing_new">Nada nuevo desde la última comprobación.</string>
<string name="comp_import_wcp">Importar .wcp</string>
<string name="comp_restore_original">Restaurar el original de este Proton</string>
@@ -581,6 +585,16 @@
<string name="drawer_prev_page">Página anterior</string>
<string name="drawer_next_page">Página siguiente</string>
<string name="drawer_hud">HUD de rendimiento</string>
<string name="drawer_hud_fps">Tasa de fotogramas</string>
<string name="drawer_hud_battery">Temperatura de la batería</string>
<string name="drawer_hud_graph">Gráfico de rendimiento</string>
<string name="drawer_hud_color">Color</string>
<string name="drawer_hud_color_mint">Menta</string>
<string name="drawer_hud_color_white">Blanco</string>
<string name="drawer_hud_color_sky">Celeste</string>
<string name="drawer_hud_color_amber">Ámbar</string>
<string name="drawer_hud_color_violet">Violeta</string>
<string name="drawer_hud_opacity">Opacidad</string>
<string name="drawer_scaling">Modo de escalado</string>
<string name="drawer_scaling_sharpness">Nitidez del escalado</string>
<string name="drawer_effects">Efectos de pantalla</string>
@@ -1788,7 +1802,8 @@
<string name="cmgr_original_kept">El original de la versión instalada se conserva: es el camino de vuelta.</string>
<string name="cmgr_not_stored">No guardado</string>
<string name="cmgr_deleted_original">Se eliminó el original de %1$s de %2$s.</string>
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones Nightlies</string>
<string name="cmgr_nightlies_only">Las descargas solo proceden de las versiones de Nightlies y DroidDeck</string>
<string name="cmgr_nightly_replaced">Error en la descarga: puede que esta compilación nocturna se haya sustituido por una más reciente. Actualiza la lista y vuelve a intentarlo.</string>
<string name="cmgr_list_predates_checksums">Esta lista de paquetes es anterior a las sumas de verificación - actualízala e inténtalo de nuevo</string>
<string name="cmgr_checksum_mismatch">La suma de verificación no coincide</string>
@@ -2044,6 +2059,9 @@
<string name="cloud_last_sync">Última sincronización %1$s</string>
<string name="cloud_never">Aún sin sincronizar</string>
<string name="cloud_done">%1$d archivos</string>
<string name="cloud_up_to_date">Ya está al día</string>
<string name="cloud_signed_out">Inicia sesión en la tienda para sincronizar las partidas</string>
<string name="cloud_not_synced">No se sincronizaron las partidas</string>
<string name="stores_eyebrow_installed">instalado</string>
<string name="stores_eyebrow_library">en tu biblioteca</string>
<string name="stores_eyebrow_store">tienda</string>
@@ -2346,7 +2364,7 @@
<string name="touch_done">Hecho</string>
<string name="touch_save_failed">No se pudo guardar la disposición táctil</string>
<string name="touch_saved">Disposición táctil guardada en Steam</string>
<string name="touch_app_id">App %1$d</string>
<string name="touch_app_id">App %1$s</string>
<string name="touch_label_hint">Etiqueta (opcional)</string>
<string name="touch_icons_missing">No se encontraron los iconos de Steam</string>
<plurals name="touch_icons_found">
@@ -2428,6 +2446,15 @@
<string name="glasses_vr_fov">Campo de visión de SteamVR</string>
<string name="glasses_vr_fov_hint">Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor.</string>
<string name="glasses_playing">Jugando en %1$s</string>
<string name="glasses_phone_recenter">Recentrar</string>
<string name="glasses_phone_steamvr_menu">Menú de SteamVR</string>
<string name="glasses_phone_switch_sticks">Sticks: manos ⇄ juego</string>
<string name="glasses_phone_controllers">Mandos: %1$s</string>
<string name="glasses_phone_keyboard">Teclado</string>
<string name="glasses_phone_session_menu">Menú de sesión</string>
<string name="glasses_phone_stop_steamvr">Detener SteamVR</string>
<string name="glasses_phone_brightness">Brillo %1$d / %2$d</string>
<string name="glasses_phone_battery">Batería %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Calibración del seguimiento</string>
<string name="glasses_calibration_hint">Reduce la deriva y alinea la referencia horizontal.</string>
<string name="glasses_calibration_saved_hint">Estas gafas usan la calibración guardada.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Napsauta kerran</string>
<string name="gesture_hold_delay">Pidon viive</string>
<string name="gesture_double_delay">Kaksoisnapautuksen aikaväli</string>
<string name="gesture_scope_game">Koskee nykyistä peliä</string>
<string name="gesture_scope_default">Kaikkien pelien oletusasetukset</string>
<string name="gesture_reset">Palauta alkuperäiset eleasetukset</string>
<string name="gesture_cancel">Peruuta</string>
<string name="gesture_action">Valitse toiminto</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Hiiren rulla</string>
<string name="gesture_threshold">Elekynnys: %1$d px</string>
<string name="gesture_debug">Näytä tunnistettu ele</string>
<string name="gesture_editor_subtitle">Hiiri- ja näppäimistöohjaus eleittäin</string>
<string name="gesture_tab_one">Yksi sormi</string>
<string name="gesture_tab_two">Kaksi sormea</string>
<string name="gesture_tab_three">Kolme sormea</string>
<string name="gesture_tab_options">Asetukset</string>
<string name="gesture_one_hint">Valitse napautuksen, vedon tai pidon toiminto.</string>
<string name="gesture_two_hint">Määritä kameran panorointi, zoomaus ja toissijaiset napsautukset.</string>
<string name="gesture_three_hint">Pidä lisäpikakomennot ulottuvillasi.</string>
<string name="gesture_options_hint">Säädä liikettä, tunnistusta ja visuaalista palautetta.</string>
<string name="gesture_defaults">Oletukset</string>
<string name="gesture_game">Tämä peli</string>
<string name="gesture_use_defaults">Käytä oletuksia</string>
<string name="gesture_inheriting">Oletusasetukset ovat käytössä. Muokkaaminen luo pelikohtaiset asetukset.</string>
<string name="gesture_save_changes">Tallenna muutokset</string>
<string name="gesture_close">Sulje</string>
<string name="gesture_action_label">TOIMINTO</string>
<string name="gesture_action_for">Kohteelle %1$s</string>
<string name="gesture_search_actions">Etsi hiiritoimintoja tai näppäimiä</string>
<string name="gesture_clear_search">Tyhjennä haku</string>
<string name="gesture_all_actions">Kaikki</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Numeronäppäimistö</string>
<string name="gesture_group_symbols">Symbolit</string>
<string name="gesture_movement">Hiiren liike</string>
<string name="gesture_movement_hint">Suora veto seuraa kosketuskohtaa. Suhteellinen veto seuraa sormen liikettä ja sopii peleihin, jotka keskittävät hiiren.</string>
<string name="gesture_middle_relative_hint">Panorointi keskipainikkeella seuraa aina sormen liikettä, jotta osoitin ei hypi.</string>
<string name="gesture_direct">Suora</string>
<string name="gesture_relative">Suhteellinen</string>
<string name="gesture_recognition">Eleiden tunnistus</string>
<string name="gesture_threshold_hint">Liikuta sormia tämän matkan verran, ennen kuin kahden tai kolmen sormen ele muuttuu panoroinniksi tai zoomaukseksi.</string>
<string name="gesture_feedback">Visuaalinen palaute</string>
<string name="gesture_mouse_behavior">Hiiripainikkeen toiminta</string>
<string name="gesture_ms">%1$d ms</string>
+28 -3
View File
@@ -504,7 +504,9 @@
<string name="comp_delete_original_title">Poistetaanko tämä alkuperäinen?</string>
<string name="comp_delete_original_body">Tämä %1$s:n alkuperäinen on aiemmasta %2$s-versiosta. Nykyisen version alkuperäinen säilytetään aina.</string>
<string name="comp_nightlies_count">Nightliesissä · %1$d</string>
<string name="comp_refresh_to_list">Päivitä nähdäksesi Nightliesin paketit.</string>
<string name="comp_channel_stable">Vakaa</string>
<string name="comp_channel_nightly">Yöversio</string>
<string name="comp_refresh_to_list">Päivitä nähdäksesi Nightliesin ja DroidDeckin paketit.</string>
<string name="comp_nothing_new">Ei mitään uutta viimeisimmän tarkistuksen jälkeen.</string>
<string name="comp_import_wcp">Tuo .wcp</string>
<string name="comp_restore_original">Palauta tämän Protonin alkuperäinen</string>
@@ -582,6 +584,16 @@
<string name="drawer_prev_page">Edellinen sivu</string>
<string name="drawer_next_page">Seuraava sivu</string>
<string name="drawer_hud">Suorituskyky-HUD</string>
<string name="drawer_hud_fps">Kuvataajuus</string>
<string name="drawer_hud_battery">Akun lämpötila</string>
<string name="drawer_hud_graph">Suorituskykykäyrä</string>
<string name="drawer_hud_color">Väri</string>
<string name="drawer_hud_color_mint">Minttu</string>
<string name="drawer_hud_color_white">Valkoinen</string>
<string name="drawer_hud_color_sky">Taivaansininen</string>
<string name="drawer_hud_color_amber">Meripihka</string>
<string name="drawer_hud_color_violet">Violetti</string>
<string name="drawer_hud_opacity">Peittävyys</string>
<string name="drawer_scaling">Skaalaustila</string>
<string name="drawer_scaling_sharpness">Skaalauksen terävyys</string>
<string name="drawer_effects">Näyttötehosteet</string>
@@ -1770,7 +1782,8 @@
<string name="cmgr_original_kept">Asennetun version alkuperäinen säilytetään: se on tie takaisin.</string>
<string name="cmgr_not_stored">Ei tallennettu</string>
<string name="cmgr_deleted_original">Poistettu %2$s:n %1$s-alkuperäinen.</string>
<string name="cmgr_nightlies_only">Lataukset tulevat vain Nightlies-julkaisuista</string>
<string name="cmgr_nightlies_only">Lataukset tulevat vain Nightlies- ja DroidDeck-julkaisuista</string>
<string name="cmgr_nightly_replaced">Lataus epäonnistui: tämä yöversio on ehkä korvattu uudemmalla. Päivitä luettelo ja yritä uudelleen.</string>
<string name="cmgr_list_predates_checksums">Tämä pakettiluettelo on tarkistussummia vanhempi - päivitä se ja yritä uudelleen</string>
<string name="cmgr_checksum_mismatch">Tarkistussumma ei täsmää</string>
@@ -2026,6 +2039,9 @@
<string name="cloud_last_sync">Viimeisin synkronointi %1$s</string>
<string name="cloud_never">Ei synkronoitu vielä</string>
<string name="cloud_done">%1$d tiedostoa</string>
<string name="cloud_up_to_date">Jo ajan tasalla</string>
<string name="cloud_signed_out">Kirjaudu kauppaan synkronoidaksesi tallennukset</string>
<string name="cloud_not_synced">Tallennuksia ei synkronoitu</string>
<string name="stores_eyebrow_installed">asennettu</string>
<string name="stores_eyebrow_library">kirjastossasi</string>
<string name="stores_eyebrow_store">kauppa</string>
@@ -2329,7 +2345,7 @@
<string name="touch_done">Valmis</string>
<string name="touch_save_failed">Kosketusasettelua ei voitu tallentaa</string>
<string name="touch_saved">Kosketusasettelu tallennettu Steamiin</string>
<string name="touch_app_id">Sovellus %1$d</string>
<string name="touch_app_id">Sovellus %1$s</string>
<string name="touch_label_hint">Nimike (valinnainen)</string>
<string name="touch_icons_missing">Steamin kuvakkeita ei löytynyt</string>
<plurals name="touch_icons_found">
@@ -2420,6 +2436,15 @@
<string name="glasses_vr_fov">SteamVR:n näkökenttä</string>
<string name="glasses_vr_fov_hint">Pienempi tuo SteamVR:n lähemmäs ja täyttää lasit; suurempi näyttää enemmän ympäriltäsi.</string>
<string name="glasses_playing">Pelataan laitteella %1$s</string>
<string name="glasses_phone_recenter">Keskitä uudelleen</string>
<string name="glasses_phone_steamvr_menu">SteamVR-valikko</string>
<string name="glasses_phone_switch_sticks">Sauvat: kädet ⇄ peli</string>
<string name="glasses_phone_controllers">Ohjaimet: %1$s</string>
<string name="glasses_phone_keyboard">Näppäimistö</string>
<string name="glasses_phone_session_menu">Istuntovalikko</string>
<string name="glasses_phone_stop_steamvr">Pysäytä SteamVR</string>
<string name="glasses_phone_brightness">Kirkkaus %1$d / %2$d</string>
<string name="glasses_phone_battery">Akku %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Seurannan kalibrointi</string>
<string name="glasses_calibration_hint">Vähentää ryömintää ja kohdistaa vaakatason.</string>
<string name="glasses_calibration_saved_hint">Tallennettu kalibrointi on käytössä näissä laseissa.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Cliquer une fois</string>
<string name="gesture_hold_delay">Délai de maintien</string>
<string name="gesture_double_delay">Intervalle du double appui</string>
<string name="gesture_scope_game">S\'applique au jeu actuel</string>
<string name="gesture_scope_default">Paramètres par défaut pour tous les jeux</string>
<string name="gesture_reset">Rétablir les paramètres des gestes d\'origine</string>
<string name="gesture_cancel">Annuler</string>
<string name="gesture_action">Choisir une action</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Molette de la souris</string>
<string name="gesture_threshold">Seuil des gestes : %1$d px</string>
<string name="gesture_debug">Afficher le geste reconnu</string>
<string name="gesture_editor_subtitle">Commandes de souris et de clavier organisées par geste</string>
<string name="gesture_tab_one">Un doigt</string>
<string name="gesture_tab_two">Deux doigts</string>
<string name="gesture_tab_three">Trois doigts</string>
<string name="gesture_tab_options">Options</string>
<string name="gesture_one_hint">Choisissez l\'action d\'un appui, d\'un glissement ou d\'un maintien.</string>
<string name="gesture_two_hint">Configurez les déplacements de caméra, le zoom et les clics secondaires.</string>
<string name="gesture_three_hint">Gardez des raccourcis supplémentaires à portée de main.</string>
<string name="gesture_options_hint">Ajustez les mouvements, la reconnaissance et le retour visuel.</string>
<string name="gesture_defaults">Valeurs par défaut</string>
<string name="gesture_game">Ce jeu</string>
<string name="gesture_use_defaults">Utiliser les valeurs par défaut</string>
<string name="gesture_inheriting">Paramètres par défaut utilisés. Une modification crée des paramètres propres au jeu.</string>
<string name="gesture_save_changes">Enregistrer les modifications</string>
<string name="gesture_close">Fermer</string>
<string name="gesture_action_label">ACTION</string>
<string name="gesture_action_for">Pour %1$s</string>
<string name="gesture_search_actions">Rechercher des actions de souris ou des touches du clavier</string>
<string name="gesture_clear_search">Effacer la recherche</string>
<string name="gesture_all_actions">Tout</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Pavé numérique</string>
<string name="gesture_group_symbols">Symboles</string>
<string name="gesture_movement">Mouvement de la souris</string>
<string name="gesture_movement_hint">Le glissement direct suit la position du toucher. Le glissement relatif suit le mouvement du doigt, utile lorsqu\'un jeu recentre la souris.</string>
<string name="gesture_middle_relative_hint">Le déplacement avec le bouton du milieu suit toujours le mouvement du doigt pour éviter les sauts du curseur.</string>
<string name="gesture_direct">Direct</string>
<string name="gesture_relative">Relatif</string>
<string name="gesture_recognition">Reconnaissance des gestes</string>
<string name="gesture_threshold_hint">Parcourez cette distance avant qu\'un geste à deux ou trois doigts soit reconnu comme un déplacement ou un zoom.</string>
<string name="gesture_feedback">Retour visuel</string>
<string name="gesture_mouse_behavior">Comportement du bouton de souris</string>
<string name="gesture_ms">%1$d ms</string>
+30 -3
View File
@@ -307,6 +307,8 @@
<string name="mode_client">Client</string>
<string name="mode_mangoapp">Superposition de performance</string>
<string name="mode_mangoapp_hint">Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session.</string>
<string name="mode_return_after_game">Revenir à DroidDeck après un jeu</string>
<string name="mode_return_after_game_hint">Quand un jeu lancé depuis DroidDeck ou un autre lanceur se ferme, Steam se ferme proprement (après la synchronisation des sauvegardes cloud) et vous y revenez. Désactivé : vous restez dans Steam.</string>
<string name="mode_steam_repair">Réparer le client Steam</string>
<string name="mode_steam_repair_hint">Retélécharge le client Steam au prochain démarrage de Steam. Utile si Steam reste bloqué sur son écran de chargement. Les jeux, la connexion et les paramètres sont conservés.</string>
<string name="mode_steam_repair_queued">Le client Steam sera retéléchargé au prochain démarrage de Steam.</string>
@@ -503,7 +505,9 @@
<string name="comp_delete_original_title">Supprimer cette version originale ?</string>
<string name="comp_delete_original_body">Cette version originale de %1$s provient d\'une version antérieure de %2$s. La version originale de la version actuelle sera toujours conservée.</string>
<string name="comp_nightlies_count">Dans Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Actualiser pour lister les paquets dans Nightlies.</string>
<string name="comp_channel_stable">Stable</string>
<string name="comp_channel_nightly">Nightly</string>
<string name="comp_refresh_to_list">Actualiser pour lister les paquets dans Nightlies et DroidDeck.</string>
<string name="comp_nothing_new">Aucun nouvel élément depuis la dernière vérification.</string>
<string name="comp_import_wcp">Importer .wcp</string>
<string name="comp_restore_original">Restaurer les fichiers originaux de ce Proton</string>
@@ -581,6 +585,16 @@
<string name="drawer_prev_page">Page précédente</string>
<string name="drawer_next_page">Page suivante</string>
<string name="drawer_hud">HUD de performance</string>
<string name="drawer_hud_fps">Fréquence d\'images</string>
<string name="drawer_hud_battery">Température de la batterie</string>
<string name="drawer_hud_graph">Graphique de performance</string>
<string name="drawer_hud_color">Couleur</string>
<string name="drawer_hud_color_mint">Menthe</string>
<string name="drawer_hud_color_white">Blanc</string>
<string name="drawer_hud_color_sky">Ciel</string>
<string name="drawer_hud_color_amber">Ambre</string>
<string name="drawer_hud_color_violet">Violet</string>
<string name="drawer_hud_opacity">Opacité</string>
<string name="drawer_scaling">Mode de mise à l\'échelle</string>
<string name="drawer_scaling_sharpness">Netteté de la mise à l\'échelle</string>
<string name="drawer_effects">Effets d\'écran</string>
@@ -1788,7 +1802,8 @@
<string name="cmgr_original_kept">L\'original de la version installée est conservé : c\'est le moyen d\'y revenir.</string>
<string name="cmgr_not_stored">Non enregistré</string>
<string name="cmgr_deleted_original">Original %1$s de %2$s supprimé.</string>
<string name="cmgr_nightlies_only">Les téléchargements proviennent uniquement des versions Nightlies</string>
<string name="cmgr_nightlies_only">Les téléchargements proviennent uniquement des versions Nightlies et DroidDeck</string>
<string name="cmgr_nightly_replaced">Échec du téléchargement : cette version nightly a peut-être été remplacée par une plus récente. Actualisez la liste et réessayez.</string>
<string name="cmgr_list_predates_checksums">Cette liste de paquets est antérieure aux sommes de contrôle - actualisez-la et réessayez</string>
<string name="cmgr_checksum_mismatch">Somme de contrôle incorrecte</string>
@@ -2044,6 +2059,9 @@
<string name="cloud_last_sync">Dernière synchro : %1$s</string>
<string name="cloud_never">Pas encore synchronisé</string>
<string name="cloud_done">%1$d fichiers</string>
<string name="cloud_up_to_date">Déjà à jour</string>
<string name="cloud_signed_out">Connectez-vous à la boutique pour synchroniser les sauvegardes</string>
<string name="cloud_not_synced">Les sauvegardes n’ont pas été synchronisées</string>
<string name="stores_eyebrow_installed">installé</string>
<string name="stores_eyebrow_library">dans votre bibliothèque</string>
<string name="stores_eyebrow_store">boutique</string>
@@ -2346,7 +2364,7 @@
<string name="touch_done">Terminé</string>
<string name="touch_save_failed">Impossible d\'enregistrer la disposition tactile</string>
<string name="touch_saved">Disposition tactile enregistrée sur Steam</string>
<string name="touch_app_id">Appli %1$d</string>
<string name="touch_app_id">Appli %1$s</string>
<string name="touch_label_hint">Libellé (facultatif)</string>
<string name="touch_icons_missing">Les icônes de Steam n\'ont pas été trouvées</string>
<plurals name="touch_icons_found">
@@ -2439,6 +2457,15 @@
<string name="glasses_vr_fov">Champ de vision SteamVR</string>
<string name="glasses_vr_fov_hint">Plus bas rapproche SteamVR et remplit les lunettes ; plus haut montre davantage autour de vous.</string>
<string name="glasses_playing">En jeu sur %1$s</string>
<string name="glasses_phone_recenter">Recentrer</string>
<string name="glasses_phone_steamvr_menu">Menu SteamVR</string>
<string name="glasses_phone_switch_sticks">Sticks : mains ⇄ jeu</string>
<string name="glasses_phone_controllers">Manettes : %1$s</string>
<string name="glasses_phone_keyboard">Clavier</string>
<string name="glasses_phone_session_menu">Menu de session</string>
<string name="glasses_phone_stop_steamvr">Arrêter SteamVR</string>
<string name="glasses_phone_brightness">Luminosité %1$d / %2$d</string>
<string name="glasses_phone_battery">Batterie %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Calibrage du suivi</string>
<string name="glasses_calibration_hint">Réduit la dérive et aligne la référence horizontale.</string>
<string name="glasses_calibration_saved_hint">Le calibrage enregistré est appliqué à ces lunettes.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">एक बार क्लिक करें</string>
<string name="gesture_hold_delay">दबाए रखने का विलंब</string>
<string name="gesture_double_delay">दो टैप के बीच का अंतराल</string>
<string name="gesture_scope_game">मौजूदा गेम पर लागू होता है</string>
<string name="gesture_scope_default">सभी गेम की डिफ़ॉल्ट सेटिंग</string>
<string name="gesture_reset">जेस्चर की मूल सेटिंग वापस लाएँ</string>
<string name="gesture_cancel">रद्द करें</string>
<string name="gesture_action">कार्रवाई चुनें</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">माउस व्हील</string>
<string name="gesture_threshold">जेस्चर की सीमा: %1$d पिक्सेल</string>
<string name="gesture_debug">पहचाना गया जेस्चर दिखाएँ</string>
<string name="gesture_editor_subtitle">जेस्चर के अनुसार माउस और कीबोर्ड कंट्रोल</string>
<string name="gesture_tab_one">एक उँगली</string>
<string name="gesture_tab_two">दो उँगलियाँ</string>
<string name="gesture_tab_three">तीन उँगलियाँ</string>
<string name="gesture_tab_options">विकल्प</string>
<string name="gesture_one_hint">चुनें कि टैप करने, खींचने या दबाए रखने पर क्या हो।</string>
<string name="gesture_two_hint">कैमरा खिसकाना, ज़ूम और दूसरे क्लिक सेट करें।</string>
<string name="gesture_three_hint">अतिरिक्त शॉर्टकट आसानी से इस्तेमाल करें।</string>
<string name="gesture_options_hint">गति, पहचान और दृश्य प्रतिक्रिया में बदलाव करें।</string>
<string name="gesture_defaults">डिफ़ॉल्ट</string>
<string name="gesture_game">यह गेम</string>
<string name="gesture_use_defaults">डिफ़ॉल्ट इस्तेमाल करें</string>
<string name="gesture_inheriting">डिफ़ॉल्ट सेटिंग इस्तेमाल हो रही हैं। बदलाव करने पर इस गेम की अलग सेटिंग बनेंगी।</string>
<string name="gesture_save_changes">बदलाव सेव करें</string>
<string name="gesture_close">बंद करें</string>
<string name="gesture_action_label">कार्रवाई</string>
<string name="gesture_action_for">%1$s के लिए</string>
<string name="gesture_search_actions">माउस की कार्रवाइयाँ या कीबोर्ड की कुंजियाँ खोजें</string>
<string name="gesture_clear_search">खोज मिटाएँ</string>
<string name="gesture_all_actions">सभी</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">नंबर पैड</string>
<string name="gesture_group_symbols">चिह्न</string>
<string name="gesture_movement">माउस की गति</string>
<string name="gesture_movement_hint">सीधे खींचना छुई गई जगह के अनुसार चलता है। सापेक्ष खींचना उँगली की गति के अनुसार चलता है और गेम के माउस को बीच में लाने पर उपयोगी है।</string>
<string name="gesture_middle_relative_hint">माउस के बीच वाले बटन से दृश्य खिसकाना हमेशा उँगली की गति के अनुसार चलता है, ताकि कर्सर न उछले।</string>
<string name="gesture_direct">डायरेक्ट</string>
<string name="gesture_relative">सापेक्ष</string>
<string name="gesture_recognition">जेस्चर की पहचान</string>
<string name="gesture_threshold_hint">दो या तीन उँगलियों का जेस्चर दृश्य खिसकाने या ज़ूम में बदलने से पहले इतनी दूरी तय करें।</string>
<string name="gesture_feedback">दृश्य प्रतिक्रिया</string>
<string name="gesture_mouse_behavior">माउस बटन का व्यवहार</string>
<string name="gesture_ms">%1$d मिलीसेकंड</string>
+28 -3
View File
@@ -504,7 +504,9 @@
<string name="comp_delete_original_title">यह ओरिजिनल हटाएं?</string>
<string name="comp_delete_original_body">यह %1$s ओरिजिनल %2$s के पहले वाले बिल्ड से है। मौजूदा बिल्ड का ओरिजिनल हमेशा रखा जाता है।</string>
<string name="comp_nightlies_count">Nightlies पर · %1$d</string>
<string name="comp_refresh_to_list">Nightlies के पैकेज सूचीबद्ध करने के लिए रीफ़्रेश करें।</string>
<string name="comp_channel_stable">स्थिर</string>
<string name="comp_channel_nightly">नाइटली</string>
<string name="comp_refresh_to_list">Nightlies और DroidDeck के पैकेज सूचीबद्ध करने के लिए रीफ़्रेश करें।</string>
<string name="comp_nothing_new">पिछली जांच के बाद कुछ नया नहीं है।</string>
<string name="comp_import_wcp">.wcp इंपोर्ट करें</string>
<string name="comp_restore_original">इस Proton का ओरिजिनल वापस लाएं</string>
@@ -582,6 +584,16 @@
<string name="drawer_prev_page">पिछला पेज</string>
<string name="drawer_next_page">अगला पेज</string>
<string name="drawer_hud">परफ़ॉर्मेंस HUD</string>
<string name="drawer_hud_fps">फ़्रेम दर</string>
<string name="drawer_hud_battery">बैटरी तापमान</string>
<string name="drawer_hud_graph">परफ़ॉर्मेंस ग्राफ़</string>
<string name="drawer_hud_color">रंग</string>
<string name="drawer_hud_color_mint">मिंट</string>
<string name="drawer_hud_color_white">सफ़ेद</string>
<string name="drawer_hud_color_sky">स्काई</string>
<string name="drawer_hud_color_amber">एम्बर</string>
<string name="drawer_hud_color_violet">वायलेट</string>
<string name="drawer_hud_opacity">ओपैसिटी</string>
<string name="drawer_scaling">स्केलिंग मोड</string>
<string name="drawer_scaling_sharpness">स्केलिंग शार्पनेस</string>
<string name="drawer_effects">स्क्रीन इफ़ेक्ट</string>
@@ -1770,7 +1782,8 @@
<string name="cmgr_original_kept">इंस्टॉल किए बिल्ड का ओरिजिनल रखा गया है: यह वापस जाने का रास्ता है।</string>
<string name="cmgr_not_stored">सेव नहीं है</string>
<string name="cmgr_deleted_original">%2$s का %1$s ओरिजिनल हटाया गया।</string>
<string name="cmgr_nightlies_only">डाउनलोड सिर्फ़ Nightlies रिलीज़ से आते हैं</string>
<string name="cmgr_nightlies_only">डाउनलोड सिर्फ़ Nightlies और DroidDeck रिलीज़ से आते हैं</string>
<string name="cmgr_nightly_replaced">डाउनलोड विफल: हो सकता है यह नाइटली बिल्ड किसी नए बिल्ड से बदल दिया गया हो। सूची रीफ़्रेश करें और फिर से कोशिश करें।</string>
<string name="cmgr_list_predates_checksums">यह पैकेज लिस्ट चेकसम से पुरानी है - इसे रीफ़्रेश करें और फिर कोशिश करें</string>
<string name="cmgr_checksum_mismatch">चेकसम मेल नहीं खाया</string>
@@ -2026,6 +2039,9 @@
<string name="cloud_last_sync">आख़िरी सिंक %1$s</string>
<string name="cloud_never">अभी सिंक नहीं हुआ</string>
<string name="cloud_done">%1$d फ़ाइलें</string>
<string name="cloud_up_to_date">पहले से अप टू डेट</string>
<string name="cloud_signed_out">सेव सिंक करने के लिए स्टोर में साइन इन करें</string>
<string name="cloud_not_synced">सेव सिंक नहीं हुए</string>
<string name="stores_eyebrow_installed">इंस्टॉल है</string>
<string name="stores_eyebrow_library">आपकी लाइब्रेरी में</string>
<string name="stores_eyebrow_store">स्टोर</string>
@@ -2329,7 +2345,7 @@
<string name="touch_done">हो गया</string>
<string name="touch_save_failed">टच लेआउट सेव नहीं हो सका</string>
<string name="touch_saved">टच लेआउट Steam में सेव हो गया</string>
<string name="touch_app_id">ऐप %1$d</string>
<string name="touch_app_id">ऐप %1$s</string>
<string name="touch_label_hint">लेबल (वैकल्पिक)</string>
<string name="touch_icons_missing">Steam के आइकॉन नहीं मिले</string>
<plurals name="touch_icons_found">
@@ -2420,6 +2436,15 @@
<string name="glasses_vr_fov">SteamVR फ़ील्ड ऑफ़ व्यू</string>
<string name="glasses_vr_fov_hint">कम करने पर SteamVR पास आता है और चश्मा भर देता है; ज़्यादा करने पर आपके आस-पास ज़्यादा दिखता है।</string>
<string name="glasses_playing">%1$s पर खेल रहे हैं</string>
<string name="glasses_phone_recenter">फिर से केंद्रित करें</string>
<string name="glasses_phone_steamvr_menu">SteamVR मेनू</string>
<string name="glasses_phone_switch_sticks">स्टिक: हाथ ⇄ गेम</string>
<string name="glasses_phone_controllers">कंट्रोलर: %1$s</string>
<string name="glasses_phone_keyboard">कीबोर्ड</string>
<string name="glasses_phone_session_menu">सत्र मेनू</string>
<string name="glasses_phone_stop_steamvr">SteamVR रोकें</string>
<string name="glasses_phone_brightness">चमक %1$d / %2$d</string>
<string name="glasses_phone_battery">बैटरी %1$d%% · %2$d °C</string>
<string name="glasses_calibration">ट्रैकिंग कैलिब्रेशन</string>
<string name="glasses_calibration_hint">ड्रिफ़्ट कम करता है और क्षैतिज संदर्भ को सीध में लाता है।</string>
<string name="glasses_calibration_saved_hint">इस चश्मे पर सेव किया गया कैलिब्रेशन लागू है।</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Egyszeri kattintás</string>
<string name="gesture_hold_delay">Nyomva tartás késleltetése</string>
<string name="gesture_double_delay">Dupla koppintás időköze</string>
<string name="gesture_scope_game">Az aktuális játékra érvényes</string>
<string name="gesture_scope_default">Alapbeállítások minden játékhoz</string>
<string name="gesture_reset">Eredeti gesztusbeállítások visszaállítása</string>
<string name="gesture_cancel">Mégse</string>
<string name="gesture_action">Művelet kiválasztása</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Egérgörgő</string>
<string name="gesture_threshold">Gesztusküszöb: %1$d px</string>
<string name="gesture_debug">Felismert gesztus megjelenítése</string>
<string name="gesture_editor_subtitle">Egér- és billentyűzetvezérlés gesztusok szerint</string>
<string name="gesture_tab_one">Egy ujj</string>
<string name="gesture_tab_two">Két ujj</string>
<string name="gesture_tab_three">Három ujj</string>
<string name="gesture_tab_options">Beállítások</string>
<string name="gesture_one_hint">Válaszd ki a koppintás, húzás vagy nyomva tartás műveletét.</string>
<string name="gesture_two_hint">Állítsd be a kamera pásztázását, a nagyítást és a másodlagos kattintásokat.</string>
<string name="gesture_three_hint">Tarts további gyorsparancsokat kéznél.</string>
<string name="gesture_options_hint">Finomhangold a mozgást, a felismerést és a vizuális visszajelzést.</string>
<string name="gesture_defaults">Alapértelmezések</string>
<string name="gesture_game">Ez a játék</string>
<string name="gesture_use_defaults">Alapbeállítások használata</string>
<string name="gesture_inheriting">Az alapbeállítások érvényesek. A szerkesztés játékhoz tartozó beállításokat hoz létre.</string>
<string name="gesture_save_changes">Módosítások mentése</string>
<string name="gesture_close">Bezárás</string>
<string name="gesture_action_label">MŰVELET</string>
<string name="gesture_action_for">Ehhez: %1$s</string>
<string name="gesture_search_actions">Egérműveletek vagy billentyűk keresése</string>
<string name="gesture_clear_search">Keresés törlése</string>
<string name="gesture_all_actions">Összes</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Numerikus billentyűzet</string>
<string name="gesture_group_symbols">Szimbólumok</string>
<string name="gesture_movement">Egérmozgás</string>
<string name="gesture_movement_hint">A közvetlen húzás az érintési helyet követi. A relatív húzás az ujj mozgását követi, ami hasznos, ha a játék középre állítja az egeret.</string>
<string name="gesture_middle_relative_hint">A középső egérgombos pásztázás mindig az ujj mozgását követi, hogy a kurzor ne ugorjon el.</string>
<string name="gesture_direct">Közvetlen</string>
<string name="gesture_relative">Relatív</string>
<string name="gesture_recognition">Gesztusfelismerés</string>
<string name="gesture_threshold_hint">Ennyit kell elmozdítani az ujjakat, mielőtt a két- vagy háromujjas gesztus pásztázássá vagy nagyítássá válik.</string>
<string name="gesture_feedback">Vizuális visszajelzés</string>
<string name="gesture_mouse_behavior">Egérgomb működése</string>
<string name="gesture_ms">%1$d ms</string>
+28 -3
View File
@@ -504,7 +504,9 @@
<string name="comp_delete_original_title">Törli ezt az eredetit?</string>
<string name="comp_delete_original_body">Ez a %1$s eredeti a %2$s egy korábbi buildjéből származik. Az aktuális build eredetije mindig megmarad.</string>
<string name="comp_nightlies_count">A Nightliesen · %1$d</string>
<string name="comp_refresh_to_list">Frissítsen a Nightliesen lévő csomagok listázásához.</string>
<string name="comp_channel_stable">Stabil</string>
<string name="comp_channel_nightly">Nightly</string>
<string name="comp_refresh_to_list">Frissítsen a Nightlies és a DroidDeck csomagjainak listázásához.</string>
<string name="comp_nothing_new">Nincs semmi új az utolsó ellenőrzés óta.</string>
<string name="comp_import_wcp">.wcp importálása</string>
<string name="comp_restore_original">Ennek a Protonnak az eredetijének visszaállítása</string>
@@ -582,6 +584,16 @@
<string name="drawer_prev_page">Előző oldal</string>
<string name="drawer_next_page">Következő oldal</string>
<string name="drawer_hud">Teljesítmény HUD</string>
<string name="drawer_hud_fps">Képkockasebesség</string>
<string name="drawer_hud_battery">Akkuhőmérséklet</string>
<string name="drawer_hud_graph">Teljesítménygrafikon</string>
<string name="drawer_hud_color">Szín</string>
<string name="drawer_hud_color_mint">Menta</string>
<string name="drawer_hud_color_white">Fehér</string>
<string name="drawer_hud_color_sky">Égszínkék</string>
<string name="drawer_hud_color_amber">Borostyán</string>
<string name="drawer_hud_color_violet">Lila</string>
<string name="drawer_hud_opacity">Átlátszatlanság</string>
<string name="drawer_scaling">Méretezési mód</string>
<string name="drawer_scaling_sharpness">Méretezési élesség</string>
<string name="drawer_effects">Képernyőeffektek</string>
@@ -1770,7 +1782,8 @@
<string name="cmgr_original_kept">A telepített build eredetije megmarad: ez a visszaút.</string>
<string name="cmgr_not_stored">Nincs tárolva</string>
<string name="cmgr_deleted_original">A %2$s %1$s eredetije törölve.</string>
<string name="cmgr_nightlies_only">A letöltések csak a Nightlies kiadásokból érkeznek</string>
<string name="cmgr_nightlies_only">A letöltések csak a Nightlies és a DroidDeck kiadásokból érkeznek</string>
<string name="cmgr_nightly_replaced">A letöltés sikertelen: lehet, hogy ezt a nightly buildet egy újabb váltotta. Frissítse a listát, és próbálja újra.</string>
<string name="cmgr_list_predates_checksums">Ez a csomaglista régebbi, mint az ellenőrzőösszegek – frissítse, és próbálja újra</string>
<string name="cmgr_checksum_mismatch">Az ellenőrzőösszeg nem egyezik</string>
@@ -2026,6 +2039,9 @@
<string name="cloud_last_sync">Utolsó szinkronizálás: %1$s</string>
<string name="cloud_never">Még nincs szinkronizálva</string>
<string name="cloud_done">%1$d fájl</string>
<string name="cloud_up_to_date">Már naprakész</string>
<string name="cloud_signed_out">Jelentkezz be az áruházba a mentések szinkronizálásához</string>
<string name="cloud_not_synced">A mentések nem lettek szinkronizálva</string>
<string name="stores_eyebrow_installed">telepítve</string>
<string name="stores_eyebrow_library">a könyvtárában</string>
<string name="stores_eyebrow_store">áruház</string>
@@ -2329,7 +2345,7 @@
<string name="touch_done">Kész</string>
<string name="touch_save_failed">Az érintőelrendezést nem sikerült elmenteni</string>
<string name="touch_saved">Az érintőelrendezés elmentve a Steambe</string>
<string name="touch_app_id">%1$d. alkalmazás</string>
<string name="touch_app_id">%1$s. alkalmazás</string>
<string name="touch_label_hint">Felirat (nem kötelező)</string>
<string name="touch_icons_missing">A Steam ikonjai nem találhatók</string>
<plurals name="touch_icons_found">
@@ -2420,6 +2436,15 @@
<string name="glasses_vr_fov">SteamVR látómező</string>
<string name="glasses_vr_fov_hint">Kisebb érték közelebb hozza a SteamVR-t és kitölti a szemüveget; nagyobb többet mutat a környezetéből.</string>
<string name="glasses_playing">Játék ezen: %1$s</string>
<string name="glasses_phone_recenter">Középre igazítás</string>
<string name="glasses_phone_steamvr_menu">SteamVR menü</string>
<string name="glasses_phone_switch_sticks">Karok: kezek ⇄ játék</string>
<string name="glasses_phone_controllers">Kontrollerek: %1$s</string>
<string name="glasses_phone_keyboard">Billentyűzet</string>
<string name="glasses_phone_session_menu">Munkamenet menü</string>
<string name="glasses_phone_stop_steamvr">SteamVR leállítása</string>
<string name="glasses_phone_brightness">Fényerő %1$d / %2$d</string>
<string name="glasses_phone_battery">Akkumulátor %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Követés kalibrálása</string>
<string name="glasses_calibration_hint">Csökkenti a sodródást, és beállítja a vízszintes referenciát.</string>
<string name="glasses_calibration_saved_hint">A szemüveg a mentett kalibrációt használja.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Klik sekali</string>
<string name="gesture_hold_delay">Jeda sebelum menahan</string>
<string name="gesture_double_delay">Interval ketuk dua kali</string>
<string name="gesture_scope_game">Berlaku untuk game saat ini</string>
<string name="gesture_scope_default">Setelan default untuk semua game</string>
<string name="gesture_reset">Pulihkan setelan gestur awal</string>
<string name="gesture_cancel">Batal</string>
<string name="gesture_action">Pilih tindakan</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Roda mouse</string>
<string name="gesture_threshold">Ambang gestur: %1$d px</string>
<string name="gesture_debug">Tampilkan gestur yang dikenali</string>
<string name="gesture_editor_subtitle">Kontrol mouse dan keyboard menurut gestur</string>
<string name="gesture_tab_one">Satu jari</string>
<string name="gesture_tab_two">Dua jari</string>
<string name="gesture_tab_three">Tiga jari</string>
<string name="gesture_tab_options">Opsi</string>
<string name="gesture_one_hint">Pilih tindakan saat mengetuk, menyeret, atau menahan.</string>
<string name="gesture_two_hint">Atur pergeseran kamera, zoom, dan klik sekunder.</string>
<string name="gesture_three_hint">Jangkau lebih banyak pintasan dengan mudah.</string>
<string name="gesture_options_hint">Sesuaikan gerakan, pengenalan, dan umpan balik visual.</string>
<string name="gesture_defaults">Default</string>
<string name="gesture_game">Game ini</string>
<string name="gesture_use_defaults">Gunakan default</string>
<string name="gesture_inheriting">Menggunakan setelan default. Mengedit akan membuat setelan khusus game.</string>
<string name="gesture_save_changes">Simpan perubahan</string>
<string name="gesture_close">Tutup</string>
<string name="gesture_action_label">TINDAKAN</string>
<string name="gesture_action_for">Untuk %1$s</string>
<string name="gesture_search_actions">Cari tindakan mouse atau tombol keyboard</string>
<string name="gesture_clear_search">Hapus pencarian</string>
<string name="gesture_all_actions">Semua</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Keypad numerik</string>
<string name="gesture_group_symbols">Simbol</string>
<string name="gesture_movement">Gerakan mouse</string>
<string name="gesture_movement_hint">Seret langsung mengikuti posisi sentuhan. Seret relatif mengikuti gerakan jari, berguna saat game memusatkan mouse.</string>
<string name="gesture_middle_relative_hint">Pergeseran dengan tombol tengah mouse selalu mengikuti gerakan jari agar kursor tidak melompat.</string>
<string name="gesture_direct">Langsung</string>
<string name="gesture_relative">Relatif</string>
<string name="gesture_recognition">Pengenalan gestur</string>
<string name="gesture_threshold_hint">Gerakkan sejauh ini sebelum gestur dua atau tiga jari menjadi pergeseran atau zoom.</string>
<string name="gesture_feedback">Umpan balik visual</string>
<string name="gesture_mouse_behavior">Perilaku tombol mouse</string>
<string name="gesture_ms">%1$d md</string>
+28 -3
View File
@@ -502,7 +502,9 @@
<string name="comp_delete_original_title">Hapus yang asli ini?</string>
<string name="comp_delete_original_body">%1$s asli ini berasal dari build %2$s yang lebih lama. Asli build saat ini selalu dipertahankan.</string>
<string name="comp_nightlies_count">Di Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Segarkan untuk melihat daftar paket di Nightlies.</string>
<string name="comp_channel_stable">Stabil</string>
<string name="comp_channel_nightly">Nightly</string>
<string name="comp_refresh_to_list">Segarkan untuk melihat daftar paket di Nightlies dan DroidDeck.</string>
<string name="comp_nothing_new">Tidak ada yang baru sejak pemeriksaan terakhir.</string>
<string name="comp_import_wcp">Impor .wcp</string>
<string name="comp_restore_original">Pulihkan yang asli dari Proton ini</string>
@@ -580,6 +582,16 @@
<string name="drawer_prev_page">Halaman sebelumnya</string>
<string name="drawer_next_page">Halaman berikutnya</string>
<string name="drawer_hud">HUD performa</string>
<string name="drawer_hud_fps">Laju bingkai</string>
<string name="drawer_hud_battery">Suhu baterai</string>
<string name="drawer_hud_graph">Grafik performa</string>
<string name="drawer_hud_color">Warna</string>
<string name="drawer_hud_color_mint">Mint</string>
<string name="drawer_hud_color_white">Putih</string>
<string name="drawer_hud_color_sky">Langit</string>
<string name="drawer_hud_color_amber">Ambar</string>
<string name="drawer_hud_color_violet">Ungu</string>
<string name="drawer_hud_opacity">Opasitas</string>
<string name="drawer_scaling">Mode penskalaan</string>
<string name="drawer_scaling_sharpness">Ketajaman penskalaan</string>
<string name="drawer_effects">Efek layar</string>
@@ -1749,7 +1761,8 @@
<string name="cmgr_original_kept">Yang asli dari build terinstal disimpan: itulah cara untuk kembali.</string>
<string name="cmgr_not_stored">Tidak tersimpan</string>
<string name="cmgr_deleted_original">Menghapus yang asli %1$s dari %2$s.</string>
<string name="cmgr_nightlies_only">Unduhan hanya berasal dari rilis Nightlies</string>
<string name="cmgr_nightlies_only">Unduhan hanya berasal dari rilis Nightlies dan DroidDeck</string>
<string name="cmgr_nightly_replaced">Unduhan gagal: build nightly ini mungkin sudah diganti dengan yang lebih baru. Segarkan daftar lalu coba lagi.</string>
<string name="cmgr_list_predates_checksums">Daftar paket ini dibuat sebelum adanya checksum - segarkan dan coba lagi</string>
<string name="cmgr_checksum_mismatch">Checksum tidak cocok</string>
@@ -2005,6 +2018,9 @@
<string name="cloud_last_sync">Sinkronisasi terakhir %1$s</string>
<string name="cloud_never">Belum disinkronkan</string>
<string name="cloud_done">%1$d file</string>
<string name="cloud_up_to_date">Sudah terbaru</string>
<string name="cloud_signed_out">Masuk ke toko untuk menyinkronkan simpanan</string>
<string name="cloud_not_synced">Simpanan tidak disinkronkan</string>
<string name="stores_eyebrow_installed">terinstal</string>
<string name="stores_eyebrow_library">dalam pustaka Anda</string>
<string name="stores_eyebrow_store">toko</string>
@@ -2306,7 +2322,7 @@
<string name="touch_done">Selesai</string>
<string name="touch_save_failed">Tata letak sentuh tidak dapat disimpan</string>
<string name="touch_saved">Tata letak sentuh disimpan ke Steam</string>
<string name="touch_app_id">App %1$d</string>
<string name="touch_app_id">App %1$s</string>
<string name="touch_label_hint">Label (opsional)</string>
<string name="touch_icons_missing">Ikon Steam tidak ditemukan</string>
<plurals name="touch_icons_found">
@@ -2395,6 +2411,15 @@
<string name="glasses_vr_fov">Bidang pandang SteamVR</string>
<string name="glasses_vr_fov_hint">Lebih rendah membawa SteamVR lebih dekat dan memenuhi kacamata; lebih tinggi menampilkan lebih banyak di sekitar Anda.</string>
<string name="glasses_playing">Bermain di %1$s</string>
<string name="glasses_phone_recenter">Pusatkan ulang</string>
<string name="glasses_phone_steamvr_menu">Menu SteamVR</string>
<string name="glasses_phone_switch_sticks">Stik: tangan ⇄ game</string>
<string name="glasses_phone_controllers">Kontroler: %1$s</string>
<string name="glasses_phone_keyboard">Keyboard</string>
<string name="glasses_phone_session_menu">Menu sesi</string>
<string name="glasses_phone_stop_steamvr">Hentikan SteamVR</string>
<string name="glasses_phone_brightness">Kecerahan %1$d / %2$d</string>
<string name="glasses_phone_battery">Baterai %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Kalibrasi pelacakan</string>
<string name="glasses_calibration_hint">Mengurangi drift dan menyelaraskan acuan horizontal.</string>
<string name="glasses_calibration_saved_hint">Kalibrasi tersimpan diterapkan ke kacamata ini.</string>
-12
View File
@@ -18,8 +18,6 @@
<string name="gesture_click_once">Clic singolo</string>
<string name="gesture_hold_delay">Ritardo della pressione</string>
<string name="gesture_double_delay">Intervallo del doppio tocco</string>
<string name="gesture_scope_game">Si applica al gioco attuale</string>
<string name="gesture_scope_default">Impostazioni predefinite per tutti i giochi</string>
<string name="gesture_reset">Ripristina le impostazioni originali dei gesti</string>
<string name="gesture_cancel">Annulla</string>
<string name="gesture_action">Scegli azione</string>
@@ -37,23 +35,16 @@
<string name="gesture_scroll">Rotellina del mouse</string>
<string name="gesture_threshold">Soglia dei gesti: %1$d px</string>
<string name="gesture_debug">Mostra il gesto riconosciuto</string>
<string name="gesture_editor_subtitle">Comandi di mouse e tastiera organizzati per gesto</string>
<string name="gesture_tab_one">Un dito</string>
<string name="gesture_tab_two">Due dita</string>
<string name="gesture_tab_three">Tre dita</string>
<string name="gesture_tab_options">Opzioni</string>
<string name="gesture_one_hint">Scegli l\'azione di un tocco, trascinamento o pressione.</string>
<string name="gesture_two_hint">Configura lo spostamento della visuale, lo zoom e i clic secondari.</string>
<string name="gesture_three_hint">Tieni altre scorciatoie a portata di mano.</string>
<string name="gesture_options_hint">Regola movimento, riconoscimento e riscontro visivo.</string>
<string name="gesture_defaults">Predefiniti</string>
<string name="gesture_game">Questo gioco</string>
<string name="gesture_use_defaults">Usa impostazioni predefinite</string>
<string name="gesture_inheriting">Sono in uso le impostazioni predefinite. Una modifica crea impostazioni specifiche del gioco.</string>
<string name="gesture_save_changes">Salva modifiche</string>
<string name="gesture_close">Chiudi</string>
<string name="gesture_action_label">AZIONE</string>
<string name="gesture_action_for">Per %1$s</string>
<string name="gesture_search_actions">Cerca azioni del mouse o tasti della tastiera</string>
<string name="gesture_clear_search">Cancella ricerca</string>
<string name="gesture_all_actions">Tutte</string>
@@ -68,12 +59,9 @@
<string name="gesture_group_numpad">Tastierino numerico</string>
<string name="gesture_group_symbols">Simboli</string>
<string name="gesture_movement">Movimento del mouse</string>
<string name="gesture_movement_hint">Il trascinamento diretto segue la posizione del tocco. Quello relativo segue il movimento del dito ed è utile quando un gioco ricentra il mouse.</string>
<string name="gesture_middle_relative_hint">Lo spostamento con il pulsante centrale segue sempre il movimento del dito per evitare salti del cursore.</string>
<string name="gesture_direct">Diretto</string>
<string name="gesture_relative">Relativo</string>
<string name="gesture_recognition">Riconoscimento dei gesti</string>
<string name="gesture_threshold_hint">Percorri questa distanza prima che un gesto con due o tre dita diventi uno spostamento o uno zoom.</string>
<string name="gesture_feedback">Riscontro visivo</string>
<string name="gesture_mouse_behavior">Comportamento del pulsante del mouse</string>
<string name="gesture_ms">%1$d ms</string>
+28 -3
View File
@@ -503,7 +503,9 @@
<string name="comp_delete_original_title">Eliminare questo originale?</string>
<string name="comp_delete_original_body">Questo originale di %1$s proviene da una build precedente di %2$s. L\'originale della build attuale viene sempre conservato.</string>
<string name="comp_nightlies_count">Nelle Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Aggiorna per elencare i pacchetti nelle Nightlies.</string>
<string name="comp_channel_stable">Stabile</string>
<string name="comp_channel_nightly">Nightly</string>
<string name="comp_refresh_to_list">Aggiorna per elencare i pacchetti nelle Nightlies e in DroidDeck.</string>
<string name="comp_nothing_new">Niente di nuovo dall\'ultimo controllo.</string>
<string name="comp_import_wcp">Importa .wcp</string>
<string name="comp_restore_original">Ripristina l\'originale di questo Proton</string>
@@ -581,6 +583,16 @@
<string name="drawer_prev_page">Pagina precedente</string>
<string name="drawer_next_page">Pagina successiva</string>
<string name="drawer_hud">HUD prestazioni</string>
<string name="drawer_hud_fps">Frequenza fotogrammi</string>
<string name="drawer_hud_battery">Temperatura batteria</string>
<string name="drawer_hud_graph">Grafico prestazioni</string>
<string name="drawer_hud_color">Colore</string>
<string name="drawer_hud_color_mint">Menta</string>
<string name="drawer_hud_color_white">Bianco</string>
<string name="drawer_hud_color_sky">Cielo</string>
<string name="drawer_hud_color_amber">Ambra</string>
<string name="drawer_hud_color_violet">Viola</string>
<string name="drawer_hud_opacity">Opacità</string>
<string name="drawer_scaling">Modalità di ridimensionamento</string>
<string name="drawer_scaling_sharpness">Nitidezza del ridimensionamento</string>
<string name="drawer_effects">Effetti schermo</string>
@@ -1780,7 +1792,8 @@
<string name="cmgr_original_kept">L\'originale della build installata viene conservato: serve per tornare indietro.</string>
<string name="cmgr_not_stored">Non salvato</string>
<string name="cmgr_deleted_original">Originale %1$s di %2$s eliminato.</string>
<string name="cmgr_nightlies_only">I download provengono solo dalle release Nightly</string>
<string name="cmgr_nightlies_only">I download provengono solo dalle release Nightly e DroidDeck</string>
<string name="cmgr_nightly_replaced">Download non riuscito: questa build nightly potrebbe essere stata sostituita da una più recente. Aggiorna l’elenco e riprova.</string>
<string name="cmgr_list_predates_checksums">Questo elenco di pacchetti è anteriore ai checksum - aggiornalo e riprova</string>
<string name="cmgr_checksum_mismatch">Checksum non corrispondente</string>
@@ -2036,6 +2049,9 @@
<string name="cloud_last_sync">Ultima sincronizzazione %1$s</string>
<string name="cloud_never">Non ancora sincronizzato</string>
<string name="cloud_done">%1$d file</string>
<string name="cloud_up_to_date">Già aggiornato</string>
<string name="cloud_signed_out">Accedi allo store per sincronizzare i salvataggi</string>
<string name="cloud_not_synced">I salvataggi non sono stati sincronizzati</string>
<string name="stores_eyebrow_installed">installato</string>
<string name="stores_eyebrow_library">nella tua libreria</string>
<string name="stores_eyebrow_store">negozio</string>
@@ -2338,7 +2354,7 @@
<string name="touch_done">Fine</string>
<string name="touch_save_failed">Impossibile salvare il layout touch</string>
<string name="touch_saved">Layout touch salvato su Steam</string>
<string name="touch_app_id">App %1$d</string>
<string name="touch_app_id">App %1$s</string>
<string name="touch_label_hint">Etichetta (opzionale)</string>
<string name="touch_icons_missing">Le icone di Steam non sono state trovate</string>
<plurals name="touch_icons_found">
@@ -2431,6 +2447,15 @@
<string name="glasses_vr_fov">Campo visivo di SteamVR</string>
<string name="glasses_vr_fov_hint">Più basso avvicina SteamVR e riempie gli occhiali; più alto mostra di più intorno a te.</string>
<string name="glasses_playing">Gioco in corso su %1$s</string>
<string name="glasses_phone_recenter">Ricentra</string>
<string name="glasses_phone_steamvr_menu">Menu di SteamVR</string>
<string name="glasses_phone_switch_sticks">Levette: mani ⇄ gioco</string>
<string name="glasses_phone_controllers">Controller: %1$s</string>
<string name="glasses_phone_keyboard">Tastiera</string>
<string name="glasses_phone_session_menu">Menu della sessione</string>
<string name="glasses_phone_stop_steamvr">Arresta SteamVR</string>
<string name="glasses_phone_brightness">Luminosità %1$d / %2$d</string>
<string name="glasses_phone_battery">Batteria %1$d%% · %2$d °C</string>
<string name="glasses_calibration">Calibrazione del tracciamento</string>
<string name="glasses_calibration_hint">Riduce la deriva e allinea il riferimento orizzontale.</string>
<string name="glasses_calibration_saved_hint">La calibrazione salvata è applicata a questi occhiali.</string>
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more