Compare commits

...
Author SHA1 Message Date
maxjivi05 11a820402c fix: native Linux games under FEX reach FEXServer, and a refused compat app no longer loops the sync
FEX launched through a script (Valve's _v2-entry-point) was judged 32-bit, so the
fast path stayed off in the FEX client while FEXServer bound its socket through it.
The fast path now follows #! lines before judging the program.

The live compat sync drops apps the client refuses GetCompatTools for instead of
reattaching forever.
2026-10-20 14:11:25 -04:00
MaxsTechReview 0f1b82aa26 Work around Android's denial of executable stacks (#1208) 2026-10-20 12:44:49 -04:00
Kurt Himebauch 8b279001a5 fix: prefetch Comet and Xbox packs for existing users (#1204) 2026-10-20 16:29:08 +00:00
Kurt Himebauch 61254786e6 feat: ship proot, DirectAudio, Xbox, Comet and hand tracking as CI-built add-on packs (#1198)
* perf: download hand tracking models and native libs on first use

* feat: build and publish add-on packs in CI, add Xbox and Comet packs

* feat: ship proot and DirectAudio drivers as CI-built packs

* test: hand tracking readiness requires the tracking pack

* ci: attach PR test-build packs to the PR release instead of Components
2026-10-20 11:18:44 -04:00
Kurt Himebauch ece1df0da8 Fix Game Pass library sync when subscription plan rows are omitted (#1193) 2026-10-20 11:14:06 -04:00
kingokksa 49d1311808 feat: arrange and hide/show store tabs from stores settings (#1121) 2026-10-20 11:12:27 -04:00
MaxsTechReview a38514afa0 fix: RetroArch fills the screen from the emulator rail (#1194) 2026-10-20 10:38:52 -04:00
MaxsTechReview 31a6c3d033 fix: open Flatpak apps in renamed app variants (#1192)
Flatpak starts bwrap with an empty environment, so the bwrap shim lost DROIDDECK_BWRAP_SOCKET and dialled the default package's socket. The session now writes the socket name to /etc/droiddeck/bwrap-socket and the shim reads it when the variable is missing.

Fixes #1092
2026-10-20 09:38:14 -04:00
FX1922 99ab6dd64e feat: Better boot movies system; Added steamdeckrepo support (#1036) 2026-10-20 09:36:25 -04:00
MaxsTechReview 9b5df0d224 perf: tile rendering for games, faster session start (#1188)
Games launched from Steam get Turnip's own per-render-pass choice between
tile memory and system memory again; TU_DEBUG=sysmem now stays with the
Steam client, whose interface it was set for. A per-game TU_DEBUG, the
debug override file and the Adreno 710/720 driver still win. On the
REDMAGIC tablet (Adreno 840) PEAK runs 46.8 -> 52.3 fps over 7 alternating
pairs and Valheim's 3D menu needs 12.6 % less GPU time per frame; light
games are unchanged.

droiddeck-seed-redists remembers the identity (inode, size, mtime) of each
prefix registry it has verified and skips re-reading it while it is
unchanged. Steam started after it on every session, ~1.8 s later.

SessionFiles keeps a ledger of the files it staged for the installed APK
and copies only the ones that changed, ~180 ms less per session start.

Session start to Steam ready: 5.57 -> 3.57 s.
2026-10-20 09:19:46 -04:00
MaxsTechReview aeb0fa57e3 fix: RPCS3 opens from the emulator rail again (#1191)
RPCS3's AppImage could not start: its own runtime unpacks the image with
a dwarfsextract it runs from a memfd, which Android refuses (EACCES), and
it has no other way to run it. Emulator AppImages for arm64 now start
through the shipped uruntime, which falls back to a file in /tmp.

RPCS3 then stopped with "Null object" in utils::shm (errno 13): it reads
/proc/sys/vm/overcommit_memory to pick memfd for guest memory, and apps
cannot read it. The guest now gets the kernel's default policy (0) there
when the real file is unreadable, like file-max.
2026-10-20 08:52:31 -04:00
MaxsTechReview ba582dea65 fix: add a picked exe the session cannot see by binding its folder (#1173)
Adding a game from a single exe outside internal storage and the game
library, such as a microSD card, only showed "This location is not
available in the session" and added nothing. #1153 made "Add all games in
this folder" bind such a folder under /root/Games, but a picked exe still
had no guest path.

addExe now binds the folder the same way when the session cannot see the
exe (the game it belongs to, or else the exe's own folder), then adds the
game. The bind is mounted from the next session start.
A folder the session already sees is not bound again, and a folder the app
cannot read is still refused.
2026-10-20 00:45:56 -04:00
MaxsTechReview 8074ec44e8 fix: route ALSA to PulseAudio for x86 Linux games under FEX (no game audio) (#1166)
fix: route ALSA to PulseAudio for x86 Linux games under FEX
2026-10-20 00:44:56 -04:00
MaxsTechReview fdab3cea82 fix: GOG games miss the registry entries their install script sets (#1159)
A GOG download only places the files, so games that look up their own registry keys (Fallout: New Vegas reads HKLM\Software\Bethesda Softworks\FalloutNV "Installed Path") said they were not installed. Every GOG launch now reads the setRegistry actions of the game's goggame-<id>.script and imports them into the prefix's 32-bit view through Proton's reg.exe, once per change of the script, so games already downloaded are fixed on their next launch.
2026-10-20 00:38:08 -04:00
MaxsTechReview b26b43fbd0 fix: find and save Steam touch layouts of non-Steam shortcuts (#1171)
The touch layout editor opened "App -<n> Layout" for a non-Steam shortcut
(an added game, the Epic Games Launcher) with LB, LT, RT, RB, both sticks
and LS/RS greyed out, so they could not be placed. Steam keys a shortcut's
controller config by the shortcut's lowercase name and logs its app id
unsigned, but the editor looked both up by the signed app id, found
neither, and fell back to a blank layout with nothing bound.

SteamTouchConfig now reads the shortcut's name from shortcuts.vdf, loads
and saves its layout under that name the way Steam does (autosave folder
and configset entry), and matches Steam's log line by the unsigned id. The
editor is titled with the game's name, and the app id in the panel is
shown unsigned. A name that is not a plain folder name (a
path separator, "." or "..", a quote, a backslash or a control character)
keeps the numeric app id. Existing configset entries are matched
without regard to case, as Steam reads them.
2026-10-20 00:34:01 -04:00
MaxsTechReview 4ba3e442a8 fix: let Wine write hooks into code FEX has translated (Ubisoft "Error at hooking API") (#1163)
fix: let Wine write hooks into code FEX has translated

Wine 11's wineserver writes another process's memory with process_vm_writev,
which the kernel refuses on a page that is not writable. FEX's WoW64 layer
keeps the pages it has translated read-only to catch self-modifying code and
re-protects them right after a program unprotects them, so WriteProcessMemory
and NtWriteVirtualMemory into ntdll failed with a partial copy and Ubisoft
games stopped at "Error at hooking API NtProtectVirtualMemory". Wine only
falls back to /proc/<pid>/mem on ENOSYS or EPERM.

libblsession now completes a single-buffer write that wineserver was refused
with EFAULT through /proc/<pid>/mem, the way Wine wrote before Wine 11. Other
programs and every other error keep the kernel's answer. The iovecs are read
with process_vm_readv, so a bad pointer still returns EFAULT, and offsets are
64-bit in the i386 build.
2026-10-20 00:31:03 -04:00
MaxsTechReview 57799bef5a fix: add games picked inside a folder game as their own games (#1155)
Picking an exe anywhere under an added folder treated it as that game, so a
folder added with "Add all games in this folder" (or a removed one) swallowed
every game below it: picking another game's exe switched the folder game's
target, or brought the removed folder game back under its old name and art.

Only treat a pick as the same game when it sits in the folder root, in the
same top-level subfolder as the game's current exe, or is another build of
that exe, and prefer the deepest matching folder.
2026-10-20 00:29:21 -04:00
MaxsTechReview cee84fd12a fix: show the app's cursor in sessions started from the front end (#1154)
gamescope makes its cursor surfaces by attaching an shm buffer, committing,
destroying the buffer and only then calling wl_pointer.set_cursor. The
compositor kept that buffer only as a reference, so set_cursor found nothing
to publish: apps run from the front end (store apps, Flatpaks, emulators)
showed DroidDeck's fallback arrow, or no cursor at all after a desktop
session had hidden its own.

Copy a role-less surface's pixels when its held buffer is destroyed and
publish that copy when the surface becomes the cursor, for as long as the
surface lives.
2026-10-20 00:28:14 -04:00
Resul Övür 48daa064c9 fix: confirm before bulk-removing unused components in Storage (#1140) 2026-10-19 23:51:11 -04:00
Resul Övür a42ad82bcb fix: keep held Steam Touch controls pressed across action-set and layer changes (#1143) 2026-10-19 23:50:29 -04:00
Kurt Himebauch 06e819f864 fix: an interrupted package install can no longer break the Linux runtime (#1078) 2026-10-19 23:40:30 -04:00
Grimarius 4c81c98f12 feat: add configurable touchscreen gestures (#940) 2026-10-20 03:22:41 +00:00
MaxsTechReview 3a8d6aaabb fix: Steam no longer sleeps again right after a paused session resumes (#1162) 2026-10-19 23:06:48 -04:00
Resul Övürandovurrsl 64b966af49 fix: keep ModeHeader search from stealing focus when recomposed (#1141)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:22 -04:00
Resul Övürandovurrsl 583c6805fd fix: keep touchpad two-finger scroll from jumping the cursor or starting a drag (#1142)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:06 -04:00
Resul Övür e8ee11612f fix: uppercase section headings in the app language (#1148)
* fix: uppercase section headings in the app language

* fix: localize store section headings
2026-10-19 21:27:50 -04:00
MaxsTechReview 7855bec6a1 fix: give x86 programs under FEX the runtime's OpenSSL 3 (libcrypto.so.3 not found) (#1167)
fix: give x86 programs under FEX the runtime's OpenSSL 3
2026-10-19 21:27:25 -04:00
MaxsTechReview 2a423a729a fix: let x86 apps under FEX use the device's fonts (Epic launcher CJK text shows boxes) (#1168)
fix: let x86 apps under FEX use the device's fonts for CJK text
2026-10-19 21:27:18 -04:00
MaxsTechReview 01f41e0553 fix: user apps open at the Desktop's resolution (#1172)
A user app (script, AppImage or Flatpak) started from its shortcut runs in
a Run session, which took the Steam resolution (720p by default). It
ignored the resolution chosen under Open Desktop and was letterboxed at
1280x720 on screens that are not 16:9.

User apps now use the Desktop's resolution once one has been chosen. Until
then, and for every other Run program, nothing changes.
2026-10-19 21:27:06 -04:00
Kurt Himebauch 3dd0045874 Restore Xbox launches from Steam (#1170) 2026-10-20 00:46:35 +00:00
Kurt Himebauch 6bbcf3ad8b Restrict Xbox credential IPC and address launch review findings (#1164)
* Restrict Xbox credentials to host backends and lease game launches

* Verify mapped PRoot backend images and retry lease heartbeat timeouts

* Bound Xbox backend shutdown and isolate service socket lifetimes

* Keep unique Xbox service sockets within Unix address limits

* Own licensed launch directories and recover abandoned views safely

* Serialize Xbox account revocation with sign-in commits and launch authorization
2026-10-19 20:10:54 -04:00
219 changed files with 15874 additions and 610 deletions

No files matched your search

+59 -10
View File
@@ -34,10 +34,28 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
outputs:
base-sha: ${{ steps.merge.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Pin and merge the APK source
id: merge
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci"
git config user.email "[email protected]"
git merge --no-ff --no-edit origin/main
- name: Record backend source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: xbox-cache
with:
@@ -80,6 +98,7 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Check co-authors
if: github.event_name == 'pull_request'
@@ -125,14 +144,17 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "[email protected]"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
@@ -179,6 +201,11 @@ jobs:
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- name: Verify backend and APK source match
env:
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -227,8 +254,6 @@ jobs:
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
@@ -265,7 +290,7 @@ jobs:
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
@@ -368,18 +393,17 @@ jobs:
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
- name: Bundle the droiddeck-esync packs
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -f tools/droiddeck-esync/release.env ]; then
. tools/droiddeck-esync/release.env
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
mkdir -p app/src/main/assets/droiddeck-esync
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
rm sync-bundle.tzst
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
for id in $revoked; do
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
@@ -515,6 +539,9 @@ jobs:
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
- name: Build
id: build
env:
@@ -553,6 +580,11 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Install Python test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
@@ -613,6 +645,14 @@ jobs:
retention-days: 30
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: droiddeck-packs
path: app/build/packs/*.tar.zst
if-no-files-found: error
retention-days: 30
compression-level: 0
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
@@ -715,8 +755,17 @@ jobs:
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
packs:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
secrets: inherit
announce:
needs: [build, sign]
needs: [build, sign, packs]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
+22
View File
@@ -176,6 +176,27 @@ jobs:
pattern: droiddeck-pr-signed-*
merge-multiple: true
path: signed
- name: The add-on packs main has not published
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ needs.authorize.outputs.run_id }}
run: |
set -euo pipefail
gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
mkdir -p pr-packs
for file in packs/*.tar.zst; do
name=$(basename "$file" .tar.zst)
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty')
if [ "$digest" = "sha256:$sha" ]; then
echo "$name: served from DroidDeck-Components $tag"
else
cp "$file" "pr-packs/$tag.tar.zst"
echo "$name: attached to the PR release as $tag.tar.zst"
fi
done
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
@@ -233,6 +254,7 @@ jobs:
first=true
fi
echo "first=$first" >> "$GITHUB_OUTPUT"
cp pr-packs/* signed/ 2>/dev/null || true
gh release upload "$tag" signed/* -R "$repo" --clobber
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
keep=$(cd signed && ls | jq -R . | jq -sc .)
+83
View File
@@ -0,0 +1,83 @@
name: Publish add-on packs
# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
# content-addressed tags (pack-<name>-<first 16 hex of sha256>). The APK from that run pins each pack's
# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
# Only main and release builds publish here; a PR test build attaches the packs main has not published
# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
on:
workflow_call:
inputs:
run_id:
description: The Build APK run whose packs to publish; empty for the calling run's own
type: string
default: ''
permissions:
contents: read
actions: read
jobs:
publish:
name: publish add-on packs
runs-on: ubuntu-24.04
timeout-minutes: 15
concurrency:
group: publish-packs
cancel-in-progress: false
steps:
- name: The packs (this run's build)
if: inputs.run_id == ''
uses: actions/download-artifact@v4
with:
name: droiddeck-packs
path: packs
- name: The packs (another Build APK run)
if: inputs.run_id != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.run_id }}
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
- uses: actions/create-github-app-token@v2
id: components-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
REPO: Droid-Deck/DroidDeck-Components
BUILD_RUN: ${{ inputs.run_id || github.run_id }}
run: |
set -euo pipefail
published_digest() {
{ gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$(published_digest "$tag" "$name.tar.zst")
if [ -z "$digest" ]; then
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
|| echo "::warning::creating $tag failed; checking whether another run published it"
digest=$(published_digest "$tag" "$name.tar.zst")
fi
if [ "$digest" != "sha256:$sha" ]; then
echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha"
exit 1
fi
echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
done
+11 -1
View File
@@ -63,9 +63,19 @@ jobs:
artifact: droiddeck-release
secrets: inherit
packs:
needs: find
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
with:
run_id: ${{ needs.find.outputs.run_id }}
secrets: inherit
publish:
name: publish the release
needs: [find, sign]
needs: [find, sign, packs]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
+3 -1
View File
@@ -47,10 +47,12 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
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. 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. 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). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
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.
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.
+30 -16
View File
@@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
return [(name): [sha256: '', size: 0L]]
}
def digest = java.security.MessageDigest.getInstance('SHA-256')
pack.withInputStream { input ->
byte[] buffer = new byte[1 << 20]
int n
while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n)
}
[(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]]
}
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
throw new GradleException('Desktop removal inventories do not match the pinned archive')
@@ -119,6 +136,10 @@ android {
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
addonPacks.each { name, pack ->
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
}
ndk {
abiFilters 'arm64-v8a'
@@ -172,6 +193,11 @@ android {
disable 'ExpiredTargetSdkVersion'
}
androidResources {
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
}
packagingOptions {
resources {
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
@@ -179,6 +205,8 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -284,22 +312,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
def verifyHandModel = tasks.register('verifyHandModel') {
def models = ['hand_landmarker.task': 'fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1',
'hand_landmarker_lite.task': '173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8']
.collectEntries { name, hash -> [(file("src/main/assets/models/$name")): hash] }
inputs.files(models.keySet())
doLast {
models.each { model, expected ->
def actual = java.security.MessageDigest.getInstance('SHA-256').digest(model.bytes).encodeHex().toString()
if (actual != expected) throw new GradleException("Hand Landmarker model checksum mismatch: ${model.name}")
}
}
}
tasks.named('preBuild').configure { dependsOn(verifyHandModel) }
dependencies {
implementation 'org.opencv:opencv:4.13.0'
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
@@ -319,7 +333,7 @@ dependencies {
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.webkit:webkit:1.12.1'
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
Binary file not shown.
+34 -1
View File
@@ -218,6 +218,8 @@ struct surface {
enum surface_role role;
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
uint32_t *idle_px;
int idle_w, idle_h;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -899,8 +901,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer)
g_cursor_shown = s;
}
static void surface_forget_idle_px(struct surface *s) {
free(s->idle_px);
s->idle_px = NULL;
s->idle_w = s->idle_h = 0;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer);
if (shm) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
int32_t stride = wl_shm_buffer_get_stride(shm);
uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4
? realloc(s->idle_px, (size_t)w * h * 4) : NULL;
if (px) {
wl_shm_buffer_begin_access(shm);
const uint8_t *src = wl_shm_buffer_get_data(shm);
for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4);
wl_shm_buffer_end_access(shm);
s->idle_px = px;
s->idle_w = w;
s->idle_h = h;
}
}
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
@@ -927,6 +951,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) {
}
static void surface_drop_idle(struct surface *s) {
surface_forget_idle_px(s);
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) wl_buffer_send_release(buffer);
}
@@ -1055,6 +1080,7 @@ static void surface_resource_destroy(struct wl_resource *r) {
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
surface_forget_idle_px(s);
if (g_grab == s) g_grab = NULL;
if (g_key_target == s) g_key_target = NULL;
if (g_ime_click == s) g_ime_click = NULL;
@@ -2042,6 +2068,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
wl_buffer_send_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
} else if (s->idle_px) {
cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy);
g_cursor_shown = s;
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
@@ -2593,7 +2622,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
uint32_t t = now_ms();
pointer_focus(target->resource, fx, fy);
if (!(k && k->is_lock))
// A button-only input must not replay a cached absolute position. A game
// may have warped its cursor since the last motion (before taking lock,
// or while releasing it), so that replay can become an unintended pan.
// Absolute touch events still carry their position, including on clicks.
if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
send_relative_motion(client, dx, dy);
for_each_pointer_of(client, sp) {
@@ -30,6 +30,7 @@ class App : Application() {
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
prefetchAddonPacks()
GpuClockPin.clearLeftover(this)
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
@@ -42,4 +43,21 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
}
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() {
val app = this
Thread({
runCatching {
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
}
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
com.droiddeck.launcher.handtracking.HandTracking.load(app)
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
}, "addon-packs").start()
}
}
@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
private var switchPoll: Runnable? = null
@@ -1124,6 +1125,13 @@ class MainActivity : ComponentActivity() {
onDismiss = { pendingSwitch = null },
)
}
showRuntimeActions?.let { release ->
com.droiddeck.launcher.ui.RuntimeActionsDialog(
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
onRemove = { showRuntimeActions = null; showRemove = true },
onDismiss = { showRuntimeActions = null },
)
}
if (showRemove) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
@@ -1280,6 +1288,7 @@ class MainActivity : ComponentActivity() {
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1533,6 +1542,27 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun resumeInterruptedPackages() {
if (runtimeChangesBlocked()) return
val first = DesktopCatalog.interrupted(this).firstOrNull()
if (first == null) {
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
return
}
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
Thread({
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "resume-pkg").start()
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
@@ -1686,12 +1716,12 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() },
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
onImportBootMovie = {
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
},
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
@@ -1979,10 +2009,14 @@ class MainActivity : ComponentActivity() {
private fun onRuntimeButton() {
if (runtimeChangesBlocked()) return
val release = available
if (removalPending || installed != null && release?.version == installed) {
if (removalPending) {
showRemove = true
return
}
if (installed != null && release?.version == installed) {
showRuntimeActions = release
return
}
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
@@ -81,6 +81,7 @@ import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.input.TouchscreenGestures
import com.droiddeck.launcher.input.ScrollTouchGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
@@ -191,6 +192,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var touchpad: TouchpadGestures
private lateinit var directTouchHold: DirectTouchHold
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private lateinit var touchscreenGestures: TouchscreenGestures
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
private var gestureGame: String? = null
private var gestureSettingsOpen by mutableStateOf(false)
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
private var gesturePointerX = 0f
private var gesturePointerY = 0f
private var gesturePointerReady = false
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
private val gestureKeysDown = mutableSetOf<Int>()
private lateinit var scrollTouch: ScrollTouchGestures
private lateinit var padMouse: PadMouse
@Volatile private var padMouseOn = false
@@ -211,6 +222,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
gesturePointerReady = false
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
padMouseCheckedAt = 0L
@@ -489,6 +501,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
}
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
touchscreenGestures = TouchscreenGestures(
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
)
refreshGestureConfig()
// A real touch target lets FrameLayout split fingers between the
// game surface and on-screen buttons, in either finger-down order.
root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event ->
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
}
padMouse = PadMouse(padMouseListener)
bridge.setTouchRoute(object : PadBridge.TouchRoute {
override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn
@@ -505,12 +527,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false)
intent.removeExtra(EXTRA_REPAIR_RUNTIME)
val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -640,6 +665,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onRepair = if (repairOffered) ({ repairRuntime() }) else null,
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
@@ -648,6 +674,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
LaunchedEffect(AgentGuest.focusRevision) {
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
else {
val snapshot = AgentGuest.focus(this@SessionActivity)
val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L }
val windows = snapshot?.optJSONArray("focusableApps")
focused?.toString() ?: windows?.let { values ->
(0 until values.length()).map { values.optLong(it) }
.filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString()
}
}
}
if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() }
}
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
cancelTouchGestures()
}
}
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
game = gestureGame,
onDismiss = { gestureSettingsOpen = false },
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
)
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
@@ -823,6 +875,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -1326,17 +1379,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
@Volatile private var installingRuntime = false
private var repairOffered by mutableStateOf(false)
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
@@ -1346,6 +1400,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installRuntime()
if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
}
if (problem == null && resume && !SessionState.stopRequested) {
SessionState.installing = "package"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package"))
problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs())
?.let { getString(R.string.session_package_resume_failed, it) }
if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package"
}
if (problem == null && desktop && !SessionState.stopRequested) {
SessionState.installing = "desktop"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
@@ -1370,7 +1431,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
if (problem != null) {
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
@@ -1418,9 +1483,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
uiHandler.post {
loading.percent = percent
loading.say(stage, LoadingState.Topic.RUNTIME, readable = true)
}
}
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return getString(R.string.session_runtime_catalog_unreachable)
runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) }
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
@@ -1543,6 +1616,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceW = width
surfaceH = height
if (resized) {
cancelTouchGestures()
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
followWindow(width, height)
@@ -1561,7 +1635,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun followChoice(): Boolean {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
}
@@ -1577,6 +1651,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
cancelTouchGestures()
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
@@ -1585,12 +1660,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Hiding the phone surface after a handoff delivers a late destruction
// callback. It must not reset the new output's size or attachment.
if (holder !== outputHolder()) return
cancelTouchGestures()
surfaceW = 0
surfaceH = 0
glassesOutputActive = false
CompositorHost.detach(holder.surface)
}
private fun cancelTouchGestures() {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
@@ -1600,8 +1684,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
private fun displayMode(): String = SessionPrefs.displayMode(this,
intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM))
private fun outputSize(): Pair<Int, Int> {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
@@ -1721,7 +1808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
Thread({
val hint = sessionEndHint(SessionState.logFile)
val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
showEnded(status, hint)
@@ -1740,6 +1827,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
repairOffered = hint?.repair == true
loading.showEnded(
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
@@ -1758,6 +1846,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
uiHandler.post { sessionOverlay.requestFocus() }
}
private fun repairRuntime() {
val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true)
closeAtOnce = true
finish()
startActivity(again)
}
private fun retrySession() {
val again = Intent(intent)
closeAtOnce = true
@@ -1765,7 +1860,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
startActivity(again)
}
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false)
private fun runtimeDamageHint(): EndHint? {
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList())
if (broken.isEmpty()) return null
Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}")
return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true)
}
private fun sessionEndHint(log: File?): EndHint? {
if (log == null || !log.isFile) return null
@@ -1989,6 +2091,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.dispatchKeyEvent(event)
}
private fun refreshGestureConfig() {
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
gesturePointerReady = false
recognizedGesture = null
}
private val gestureOutput = object : TouchscreenGestures.Output {
override fun moveTo(x: Float, y: Float) {
val rect = drawnRect() ?: return
if (pointerLocked) {
if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true)
} else movePointer(x, y)
gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right)
gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom)
gesturePointerReady = true
cursorVisible = false
}
override fun moveBy(dx: Float, dy: Float, relative: Boolean) {
val rect = drawnRect() ?: return
if (relative || pointerLocked) {
val scale = SessionState.outputSize.first / rect.width()
WaylandCompositor.sendPointerDelta(dx * scale, dy * scale)
} else moveTo(gesturePointerX + dx, gesturePointerY + dy)
}
override fun button(code: Int, down: Boolean) {
WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0)
}
override fun prepareRelativePan() {
if (pointerLocked) return
val rect = drawnRect() ?: return
// Gamescope retains the last absolute position while a game can warp
// its own cursor to the center. Its first unlocked motion must start
// from that same origin, before the game switches to pointer lock.
moveTo(rect.centerX(), rect.centerY())
}
override fun key(code: Int, down: Boolean) {
if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code)
if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } }
override fun gesture(gesture: TouchscreenGestures.Gesture) {
recognizedGesture = gesture
uiHandler.removeCallbacks(clearRecognizedGesture)
uiHandler.postDelayed(clearRecognizedGesture, 1200)
}
}
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) {
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
@@ -2279,6 +2430,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
return when {
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
else -> SessionPrefs.TOUCH_DIRECT
}
@@ -2291,6 +2443,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
touchpad.sensitivity = 1.15f * speed
}
if (next == routedTouchMode) return
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
gesturePointerReady = false
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
@@ -2327,6 +2481,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) {
if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false
return touchscreenGestures.onTouch(event)
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -2558,7 +2717,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun sendGuestKey(code: Int, down: Boolean) {
if (down && pipUi) return
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
private fun pipBounds(): android.graphics.Rect {
@@ -2713,7 +2872,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
uiHandler.removeCallbacks(clearRecognizedGesture)
gestureSettingsOpen = false
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
@@ -2963,7 +3126,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun glassesOutputSize(): Pair<Int, Int>? {
if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null
val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val display = com.droiddeck.launcher.session.SessionDisplay
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
val size = display.resolveChoice(panel, choice)
@@ -3089,6 +3252,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && ::padMouse.isInitialized) padMouse.reset()
if (runtimeRemovalBlocked) return
if (!hasFocus) {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
}
if (hasFocus && !pipUi) {
goFullscreen()
refreshClipboard()
@@ -3199,6 +3366,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
private var startedScreens = 0
@@ -0,0 +1,137 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
/**
* A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
* DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
* 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>) {
sealed interface State {
data object Missing : State
data class Downloading(val progress: Float) : State
data object Failed : State
data object Ready : State
}
private val mutableState = MutableStateFlow<State>(State.Missing)
val state: StateFlow<State> = mutableState.asStateFlow()
private val lock = Any()
val available: Boolean get() = sha256.isNotEmpty()
private val tag get() = "pack-$name-${sha256.take(16)}"
private val urls get() = listOfNotNull(
"https://github.com/$REPO/releases/download/$tag/$name.tar.zst",
BuildConfig.PR_NUMBER.takeIf { it > 0 }?.let { "https://github.com/$PR_REPO/releases/download/pr-$it/$tag.tar.zst" },
)
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 file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
fun refresh(context: Context): State {
val now = mutableState.value
if (now is State.Downloading) return now
return (if (installed(context)) State.Ready else if (now == State.Failed) State.Failed else State.Missing)
.also { mutableState.value = it }
}
/** Starts the download in the background unless the pack is already present or on its way. */
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
val app = context.applicationContext
synchronized(lock) {
if (installed(app)) { mutableState.value = State.Ready; return }
if (mutableState.value is State.Downloading) return
mutableState.value = State.Downloading(0f)
}
scope.launch {
val ok = runCatching { install(app) }.onFailure { Log.w(TAG, "$name: install failed", it) }.getOrDefault(false)
mutableState.value = if (ok) State.Ready else State.Failed
if (ok) onReady(app)
}
}
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
@JvmOverloads
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
if (installed(context)) return true
ensure(context)
while (running()) {
val now = state.value
onState(now)
when (now) {
State.Ready -> return installed(context)
State.Failed -> return false
else -> try { Thread.sleep(250) } catch (e: InterruptedException) { return false }
}
}
return false
}
private fun install(context: Context): Boolean {
if (!available) {
Log.w(TAG, "$name: this build has no pack")
return false
}
val root = root(context).apply { mkdirs() }
val archive = File(root, "$sha256.tar.zst")
val downloaded = urls.any { url ->
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
.also { if (!it) archive.delete() }
}
if (!downloaded) return false
val staging = File(root, "$sha256.partial")
staging.deleteRecursively()
try {
if (!FileInputStream(archive).use { TarZst.extract(it, staging) }) return false
check(required.all { File(staging, it).isFile }) { "$name pack is incomplete" }
File(staging, COMPLETE).writeText(sha256)
val target = dir(context)
target.deleteRecursively()
check(staging.renameTo(target)) { "could not move the $name pack into place" }
} finally {
staging.deleteRecursively()
archive.delete()
}
root.listFiles().orEmpty().filter { it.name != sha256 }.forEach { it.deleteRecursively() }
return true
}
companion object {
private const val TAG = "AddonPack"
private const val REPO = "Droid-Deck/DroidDeck-Components"
private const val PR_REPO = "Droid-Deck/DroidDeck-CI"
const val COMPLETE = ".complete"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@JvmField
val PROOT = AddonPack("proot", BuildConfig.PACK_PROOT_SHA256, BuildConfig.PACK_PROOT_SIZE,
listOf("libproot.so", "libproot-loader.so"))
@JvmField
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
}
}
@@ -225,6 +225,9 @@ object DeviceReport {
k("Installed version", LinuxRuntimeInstaller.installedVersion(context))
k("Runtime ready", LinuxRuntime.isInstalled(context))
k("Desktop installed", DesktopCatalog.desktopInstalled(context))
k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" })
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList())
k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}")
k("Runtime root", LinuxRuntime.rootDir(context).path)
h("Settings in effect")
@@ -204,8 +204,10 @@ object AddedGames {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
val entries = AddedExes.list(context)
fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe)
entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
@@ -220,6 +222,24 @@ object AddedGames {
return null
}
private fun currentExe(context: Context, entries: List<AddedExes.Entry>, folder: File): File? =
SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File)
?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) }
?: rank(folder).exes.firstOrNull()
private fun sameGame(folder: File, current: File?, pick: File): Boolean {
val base = canonical(folder)
if (current == null || pick.parentFile?.let(::canonical) == base) return true
val known = canonical(current)
if (!inside(known, base) || known.substringBeforeLast('/') == base) return true
val picked = canonical(pick)
fun top(p: String) = p.removePrefix("$base/").substringBefore('/')
fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() }
return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) }
}
private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$")
fun addExe(context: Context, exe: File): AddResult {
val app = context.applicationContext
if (!exe.isFile) return AddResult.NotFound
@@ -228,7 +248,10 @@ object AddedGames {
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
}
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
if (guestPath(app, exe) == null) {
(owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) }
if (guestPath(app, exe) == null) return AddResult.Unreachable
}
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
@@ -153,7 +153,7 @@ object SteamAppInfo {
return String(bytes, Charsets.UTF_8)
}
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
internal fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (b.hasRemaining()) {
val type = b.get().toInt() and 0xFF
@@ -1,8 +1,10 @@
package com.droiddeck.launcher.glasses
internal enum class HandModel(val asset: String, val sha256: String) {
FULL("models/hand_landmarker.task", "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE("models/hand_landmarker_lite.task", "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
import com.droiddeck.launcher.handtracking.TrackingPack
internal enum class HandModel(val file: String, val sha256: String) {
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
companion object {
val DEFAULT = FULL
@@ -1,7 +1,9 @@
package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.handtracking.TrackingPack
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
@@ -11,7 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@@ -170,11 +171,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
} }
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
val bytes=context.assets.open(selected.asset).use { it.readBytes() }
val bytes=TrackingPack.file(context,selected.file).readBytes()
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetPath(selected.asset)
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(ByteBuffer.allocateDirect(bytes.size).put(bytes).apply { rewind() })
.setDelegate(if(useGpu) Delegate.GPU else Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO).setNumHands(maxHands).setMinHandDetectionConfidence(0.6f)
.setMinHandPresenceConfidence(0.6f).setMinTrackingConfidence(0.6f).build())
@@ -325,7 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
}
private fun loop() {
try { check(OpenCVLoader.initLocal()) } catch(e: Throwable) { message=context.getString(R.string.spatial_opencv_error, e.message.orEmpty());return }
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
if(state is AddonPack.State.Downloading)
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
}
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
var grayL=Mat();var grayR=Mat();val rgba=Mat()
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
@@ -336,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
var rate=0f
try {
check(OpenCVLoader.initLocal()) { context.getString(R.string.spatial_opencv_load_error) }
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
joyConFlow=StereoJoyConFlow()
while(running) {
while(true) {
@@ -39,8 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.Executors
import kotlin.math.abs
@@ -188,9 +186,8 @@ internal class HandCamera(
private fun prepare() {
if (stopped) return
runCatching {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
val model = TrackingPack.model(context, TrackingPack.POSE)
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO)
@@ -305,7 +302,6 @@ internal class HandCamera(
companion object {
private const val TAG = "HandCamera"
private const val MODEL = "handtracking/pose_landmarker_lite.task"
private const val ANALYSIS_WIDTH = 960
private const val ANALYSIS_HEIGHT = 720
private const val MIN_CONFIDENCE = 0.5f
@@ -13,8 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.hypot
import kotlin.math.max
@@ -134,7 +132,6 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height)
companion object {
private const val MODEL = "handtracking/hand_landmarker.task"
private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)
private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY)
private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX)
@@ -153,10 +150,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
private const val MIN_CONFIDENCE = 0.4f
fun open(context: Context): HandModel {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
val model = TrackingPack.model(context, TrackingPack.HAND)
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(mode)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
@@ -16,9 +17,10 @@ internal object HandTracking {
val side: String? = null,
val permitted: Boolean = false,
val sides: Set<String> = emptySet(),
val downloaded: Boolean = false,
) {
val usable: Boolean get() = sides.isNotEmpty()
val ready: Boolean get() = enabled && permitted && usable
val ready: Boolean get() = enabled && permitted && usable && downloaded
}
private val state = MutableStateFlow(Settings())
@@ -38,8 +40,11 @@ internal object HandTracking {
side = prefs.getString(SIDE, null),
permitted = permitted(context),
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
downloaded = TrackingPack.installed(context),
)
state.value = next
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
TrackingPack.ensure(context)
return next
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.handtracking
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.AddonPack
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
internal object TrackingPack {
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
const val POSE = "pose_landmarker_lite.task"
private const val OPENCV = "libopencv_java4.so"
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
private const val TAG = "TrackingPack"
private val pack = AddonPack.TRACKING
val state get() = pack.state
val size get() = pack.size
private val lock = Any()
@Volatile private var nativeLoaded = false
fun installed(context: Context) = pack.installed(context)
fun refresh(context: Context) = pack.refresh(context)
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
fun file(context: Context, name: String): File = pack.file(context, name)
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
/**
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
*/
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
}
}
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
}
@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
object GestureKeys {
val codes: Map<String, Int> = linkedMapOf(
"ESC" to 1,
"1" to 2,
"2" to 3,
"3" to 4,
"4" to 5,
"5" to 6,
"6" to 7,
"7" to 8,
"8" to 9,
"9" to 10,
"0" to 11,
"MINUS" to 12,
"EQUAL" to 13,
"BKSP" to 14,
"TAB" to 15,
"Q" to 16,
"W" to 17,
"E" to 18,
"R" to 19,
"T" to 20,
"Y" to 21,
"U" to 22,
"I" to 23,
"O" to 24,
"P" to 25,
"BRACKET_LEFT" to 26,
"BRACKET_RIGHT" to 27,
"ENTER" to 28,
"CTRL_L" to 29,
"A" to 30,
"S" to 31,
"D" to 32,
"F" to 33,
"G" to 34,
"H" to 35,
"J" to 36,
"K" to 37,
"L" to 38,
"SEMICOLON" to 39,
"APOSTROPHE" to 40,
"GRAVE" to 41,
"SHIFT_L" to 42,
"BACKSLASH" to 43,
"Z" to 44,
"X" to 45,
"C" to 46,
"V" to 47,
"B" to 48,
"N" to 49,
"M" to 50,
"COMMA" to 51,
"PERIOD" to 52,
"SLASH" to 53,
"SHIFT_R" to 54,
"KP_MULTIPLY" to 55,
"ALT_L" to 56,
"SPACE" to 57,
"CAPS_LOCK" to 58,
"F1" to 59,
"F2" to 60,
"F3" to 61,
"F4" to 62,
"F5" to 63,
"F6" to 64,
"F7" to 65,
"F8" to 66,
"F9" to 67,
"F10" to 68,
"NUM_LOCK" to 69,
"SCROLL_LOCK" to 70,
"KP_7" to 71,
"KP_8" to 72,
"KP_9" to 73,
"KP_SUBTRACT" to 74,
"KP_4" to 75,
"KP_5" to 76,
"KP_6" to 77,
"KP_ADD" to 78,
"KP_1" to 79,
"KP_2" to 80,
"KP_3" to 81,
"KP_0" to 82,
"KP_DEL" to 83,
"F11" to 87,
"F12" to 88,
"KP_ENTER" to 96,
"CTRL_R" to 97,
"KP_DIVIDE" to 98,
"PRTSCN" to 99,
"ALT_R" to 100,
"HOME" to 102,
"UP" to 103,
"PRIOR" to 104,
"LEFT" to 105,
"RIGHT" to 106,
"END" to 107,
"DOWN" to 108,
"NEXT" to 109,
"INSERT" to 110,
"DEL" to 111,
)
@JvmStatic fun code(name: String): Int? = codes[name]
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.input
import android.content.Context
/** Global defaults with optional overrides for the currently focused game. */
object GesturePrefs {
private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE)
fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game")
fun read(context: Context, game: String? = null): TouchGestureConfig {
val p = prefs(context)
val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null)
return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json)
}
fun save(context: Context, game: String?, config: TouchGestureConfig) {
prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply()
}
fun clear(context: Context, game: String?) {
prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply()
}
}
@@ -2,10 +2,14 @@ package com.droiddeck.launcher.input
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.SteamAppInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Locale
object SteamTouchConfig {
private const val TAG = "SteamTouch"
@@ -125,17 +129,37 @@ object SteamTouchConfig {
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
fun steamId(appId: Int): String = Integer.toUnsignedString(appId)
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
fun gameName(context: Context, appId: Int): String? = try {
fun gameName(context: Context, appId: Int): String? = if (appId < 0) shortcutName(context, appId) else try {
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
(kv.child("AppState") ?: kv).string("name")
} catch (_: Exception) {
null
}
fun steamKey(context: Context, appId: Int): String =
if (appId < 0) shortcutName(context, appId)?.lowercase(Locale.ROOT)
?.takeIf { name -> name != "." && name != ".." && name.none { it == '"' || it == '\\' || it == '/' || it < ' ' } } ?: steamId(appId)
else steamId(appId)
private fun shortcutName(context: Context, appId: Int): String? {
val account = accountId(context) ?: return null
return try {
val bytes = File(steamRoot(context), "userdata/$account/config/shortcuts.vdf").readBytes()
val root = SteamAppInfo.readMap(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), null)
(root["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance<Map<*, *>>()
?.firstOrNull { (it["appid"] as? String)?.toIntOrNull() == appId }
?.let { (it["AppName"] ?: it["appname"]) as? String }?.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
}
}
fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context)
fun configDir(context: Context): File? {
@@ -158,10 +182,11 @@ object SteamTouchConfig {
fun load(context: Context, appId: Int): Config {
val dir = configDir(context)
val file = dir?.let { deviceConfig(context, it, appId) }
?: dir?.let { autosaveConfig(it, appId) }
val key = steamKey(context, appId)
val file = dir?.let { deviceConfig(context, it, key) }
?: dir?.let { autosaveConfig(it, key) }
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
?: dir?.let { configsetConfig(context, it, appId) }
?: dir?.let { configsetConfig(context, it, key) }
if (file == null) {
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
return Config(null, null, null, emptyMap())
@@ -193,7 +218,7 @@ object SteamTouchConfig {
}
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
if (touchIndex == null) null else {
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID ${steamId(appId)}, Controller $touchIndex: ([^\n]+)""")
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
}
@@ -201,22 +226,22 @@ object SteamTouchConfig {
null
}
private fun autosaveConfig(dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
private fun autosaveConfig(dir: File, key: String): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(key)
if (set?.string("autosave") == "1") File(dir, "$key/controller_mobile_touch.vdf").takeIf { it.isFile } else null
} catch (_: Exception) {
null
}
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
private fun configsetConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, CONFIGSET, key)
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
private fun deviceConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, DEVICE_CONFIGSET, key)
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
private fun entryConfig(context: Context, dir: File, configset: String, key: String): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(key)
when {
set == null -> null
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
set.string("autosave") == "1" -> File(dir, "$key/controller_mobile_touch.vdf")
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
else -> null
@@ -333,6 +358,11 @@ object SteamTouchConfig {
}.sortedBy { it.type }
}
fun remapHeld(held: Map<Int, Int?>, next: List<Element>): Map<Int, Element> {
val byType = next.associateBy { it.type }
return held.mapNotNull { (id, type) -> byType[type]?.let { id to it } }.toMap()
}
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
@@ -359,7 +389,8 @@ object SteamTouchConfig {
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
SteamTouchBindings.rewrite(text, ref, change) ?: text
}
val target = File(dir, "$appId/controller_mobile_touch.vdf")
val key = steamKey(context, appId)
val target = File(dir, "$key/controller_mobile_touch.vdf")
val old = config.layouts
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
val written = sets.map { (id, elements) ->
@@ -377,27 +408,27 @@ object SteamTouchConfig {
val tmp = File(target.path + ".tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) return null
selectAutosave(dir, appId)
dropDeviceEntry(dir, appId)
selectAutosave(dir, key)
dropDeviceEntry(dir, key)
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
return target
}
private fun dropDeviceEntry(dir: File, appId: Int) {
private fun dropDeviceEntry(dir: File, key: String) {
val file = File(dir, DEVICE_CONFIGSET)
if (!file.isFile) return
val text = file.readText()
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
if (!block.containsMatchIn(text)) return
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
}
private fun selectAutosave(dir: File, appId: Int) {
private fun selectAutosave(dir: File, key: String) {
val file = File(dir, CONFIGSET)
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
val entry = "\t\"$key\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
val next = if (block.containsMatchIn(text)) text.replace(block) { "\n$entry" }
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
}
@@ -154,7 +154,7 @@ class SteamTouchControls(
actionSet = state.actionSet
layers = state.layers
appId = app
rebuild()
rebuild(keepHeld = true)
}
}
@@ -175,7 +175,7 @@ class SteamTouchControls(
refreshState()
}
private fun rebuild() {
private fun rebuild(keepHeld: Boolean = false) {
elements = SteamTouchConfig.elementsFor(config, actionSet, layers)
val nextOptions = pendingOptions ?: config.layouts?.options ?: SteamTouchConfig.Options()
if (nextOptions != options) { options = nextOptions; onOptions(options) }
@@ -183,7 +183,7 @@ class SteamTouchControls(
rebuildVisuals()
if (!editing) {
Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls")
releaseAll()
if (keepHeld) keepHeldFingers() else releaseAll()
}
invalidate()
}
@@ -590,6 +590,13 @@ class SteamTouchControls(
invalidate()
}
private fun keepHeldFingers() {
val moved = SteamTouchConfig.remapHeld(fingers.mapValues { it.value.element?.type }, live())
fingers.keys.retainAll(moved.keys)
fingers.replaceAll { id, f -> Finger(moved.getValue(id), f.x, f.y, f.toolbar) }
publish()
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
@@ -958,7 +965,7 @@ class SteamTouchControls(
BIG_PICTURE -> "Steam"
else -> gameNames[appId] ?: run {
val app = appId
gameNames[app] = context.getString(R.string.touch_app_id, app)
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app))
loader.execute {
val name = SteamTouchConfig.gameName(context, app) ?: return@execute
handler.post { gameNames[app] = name; invalidate() }
@@ -0,0 +1,226 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
import org.json.JSONObject
data class TouchGestureConfig(
val tapEnabled: Boolean = true,
val tapAction: String = ACTION_LEFT_CLICK,
val dragEnabled: Boolean = true,
val dragAction: String = PAN_LEFT_CLICK_DRAG,
val longPressEnabled: Boolean = false,
val longPressAction: String = ACTION_RIGHT_CLICK,
val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val longPressDelay: Int = DEFAULT_DELAY_MS,
val doubleTapEnabled: Boolean = true,
val doubleTapDelay: Int = DEFAULT_DELAY_MS,
val twoFingerDragEnabled: Boolean = true,
val twoFingerDragAction: String = PAN_ARROW_KEYS,
val pinchEnabled: Boolean = true,
val pinchAction: String = ZOOM_SCROLL_WHEEL,
val twoFingerTapEnabled: Boolean = true,
val twoFingerTapAction: String = ACTION_RIGHT_CLICK,
val twoFingerHoldEnabled: Boolean = true,
val twoFingerHoldAction: String = ACTION_MIDDLE_CLICK,
val twoFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val twoFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val threeFingerTapEnabled: Boolean = true,
val threeFingerTapAction: String = ACTION_SHOW_KEYBOARD,
val threeFingerDragEnabled: Boolean = false,
val threeFingerDragAction: String = PAN_ARROW_KEYS,
val threeFingerHoldEnabled: Boolean = true,
val threeFingerHoldAction: String = ACTION_KEY_ESC,
val threeFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val threeFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val showGestureDebugOverlay: Boolean = false,
val gestureThreshold: Int = DEFAULT_GESTURE_THRESHOLD,
val mouseDragMovementMode: String = MOUSE_DRAG_MOVEMENT_DIRECT,
) {
fun toJson(): String {
return JSONObject().apply {
put(KEY_TAP_ENABLED, tapEnabled)
put(KEY_TAP_ACTION, tapAction)
put(KEY_DRAG_ENABLED, dragEnabled)
put(KEY_DRAG_ACTION, dragAction)
put(KEY_LONG_PRESS_ENABLED, longPressEnabled)
put(KEY_LONG_PRESS_ACTION, longPressAction)
put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior)
put(KEY_LONG_PRESS_DELAY, longPressDelay)
put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled)
put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay)
put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled)
put(KEY_TWO_FINGER_DRAG_ACTION, twoFingerDragAction)
put(KEY_PINCH_ENABLED, pinchEnabled)
put(KEY_PINCH_ACTION, pinchAction)
put(KEY_TWO_FINGER_TAP_ENABLED, twoFingerTapEnabled)
put(KEY_TWO_FINGER_TAP_ACTION, twoFingerTapAction)
put(KEY_TWO_FINGER_HOLD_ENABLED, twoFingerHoldEnabled)
put(KEY_TWO_FINGER_HOLD_ACTION, twoFingerHoldAction)
put(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, twoFingerHoldMouseBehavior)
put(KEY_TWO_FINGER_HOLD_DELAY, twoFingerHoldDelay)
put(KEY_THREE_FINGER_TAP_ENABLED, threeFingerTapEnabled)
put(KEY_THREE_FINGER_TAP_ACTION, threeFingerTapAction)
put(KEY_THREE_FINGER_DRAG_ENABLED, threeFingerDragEnabled)
put(KEY_THREE_FINGER_DRAG_ACTION, threeFingerDragAction)
put(KEY_THREE_FINGER_HOLD_ENABLED, threeFingerHoldEnabled)
put(KEY_THREE_FINGER_HOLD_ACTION, threeFingerHoldAction)
put(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, threeFingerHoldMouseBehavior)
put(KEY_THREE_FINGER_HOLD_DELAY, threeFingerHoldDelay)
put(KEY_SHOW_GESTURE_DEBUG_OVERLAY, showGestureDebugOverlay)
put(KEY_GESTURE_THRESHOLD, gestureThreshold)
put(KEY_MOUSE_DRAG_MOVEMENT_MODE, mouseDragMovementMode)
}.toString()
}
companion object {
const val DEFAULT_DELAY_MS = 300
const val DEFAULT_GESTURE_THRESHOLD = 40
const val ACTION_LEFT_CLICK = "left_click"
const val ACTION_RIGHT_CLICK = "right_click"
const val ACTION_MIDDLE_CLICK = "middle_click"
const val MOUSE_BEHAVIOR_HOLD = "hold"
const val MOUSE_BEHAVIOR_CLICK = "click"
const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct"
const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative"
const val ACTION_SHOW_KEYBOARD = "show_keyboard"
const val ACTION_KEY_ESC = "key_ESC"
const val ACTION_KEY_TILDE = "key_TILDE"
const val PAN_WASD = "wasd"
const val PAN_INVERTED_WASD = "inverted_wasd"
const val PAN_ARROW_KEYS = "arrow_keys"
const val PAN_INVERTED_ARROW_KEYS = "inverted_arrow_keys"
const val PAN_MIDDLE_MOUSE = "middle_mouse_pan"
const val PAN_INVERTED_MIDDLE_MOUSE = "inverted_middle_mouse_pan"
const val PAN_LEFT_CLICK_DRAG = "left_click_drag"
const val PAN_RIGHT_CLICK_DRAG = "right_click_drag"
const val ZOOM_SCROLL_WHEEL = "scroll_wheel"
const val ZOOM_PLUS_MINUS = "plus_minus"
const val ZOOM_PAGE_UP_DOWN = "page_up_down"
private const val KEY_TAP_ENABLED = "tapEnabled"
private const val KEY_TAP_ACTION = "tapAction"
private const val KEY_DRAG_ENABLED = "dragEnabled"
private const val KEY_DRAG_ACTION = "dragAction"
private const val KEY_LONG_PRESS_ENABLED = "longPressEnabled"
private const val KEY_LONG_PRESS_ACTION = "longPressAction"
private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior"
private const val KEY_LONG_PRESS_DELAY = "longPressDelay"
private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled"
private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay"
private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled"
private const val KEY_TWO_FINGER_DRAG_ACTION = "twoFingerDragAction"
private const val KEY_PINCH_ENABLED = "pinchEnabled"
private const val KEY_PINCH_ACTION = "pinchAction"
private const val KEY_TWO_FINGER_TAP_ENABLED = "twoFingerTapEnabled"
private const val KEY_TWO_FINGER_TAP_ACTION = "twoFingerTapAction"
private const val KEY_TWO_FINGER_HOLD_ENABLED = "twoFingerHoldEnabled"
private const val KEY_TWO_FINGER_HOLD_ACTION = "twoFingerHoldAction"
private const val KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR = "twoFingerHoldMouseBehavior"
private const val KEY_TWO_FINGER_HOLD_DELAY = "twoFingerHoldDelay"
private const val KEY_THREE_FINGER_TAP_ENABLED = "threeFingerTapEnabled"
private const val KEY_THREE_FINGER_TAP_ACTION = "threeFingerTapAction"
private const val KEY_THREE_FINGER_DRAG_ENABLED = "threeFingerDragEnabled"
private const val KEY_THREE_FINGER_DRAG_ACTION = "threeFingerDragAction"
private const val KEY_THREE_FINGER_HOLD_ENABLED = "threeFingerHoldEnabled"
private const val KEY_THREE_FINGER_HOLD_ACTION = "threeFingerHoldAction"
private const val KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR = "threeFingerHoldMouseBehavior"
private const val KEY_THREE_FINGER_HOLD_DELAY = "threeFingerHoldDelay"
private const val KEY_SHOW_GESTURE_DEBUG_OVERLAY = "showGestureDebugOverlay"
private const val KEY_GESTURE_THRESHOLD = "gestureThreshold"
private const val KEY_MOUSE_DRAG_MOVEMENT_MODE = "mouseDragMovementMode"
private fun fallbackDefaults(): TouchGestureConfig = TouchGestureConfig(
twoFingerHoldEnabled = false,
threeFingerTapEnabled = false,
threeFingerHoldEnabled = false,
)
fun fromJson(json: String?): TouchGestureConfig {
if (json.isNullOrBlank()) return fallbackDefaults()
return try {
val obj = JSONObject(json)
TouchGestureConfig(
tapEnabled = obj.optBoolean(KEY_TAP_ENABLED, true),
tapAction = obj.optString(KEY_TAP_ACTION, ACTION_LEFT_CLICK),
dragEnabled = obj.optBoolean(KEY_DRAG_ENABLED, true),
dragAction = obj.optString(KEY_DRAG_ACTION, PAN_LEFT_CLICK_DRAG),
longPressEnabled = obj.optBoolean(KEY_LONG_PRESS_ENABLED, false),
longPressAction = obj.optString(KEY_LONG_PRESS_ACTION, ACTION_RIGHT_CLICK),
longPressMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS),
doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true),
doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS),
twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true),
twoFingerDragAction = obj.optString(KEY_TWO_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
pinchEnabled = obj.optBoolean(KEY_PINCH_ENABLED, true),
pinchAction = obj.optString(KEY_PINCH_ACTION, ZOOM_SCROLL_WHEEL),
twoFingerTapEnabled = obj.optBoolean(KEY_TWO_FINGER_TAP_ENABLED, true),
twoFingerTapAction = obj.optString(KEY_TWO_FINGER_TAP_ACTION, ACTION_RIGHT_CLICK),
twoFingerHoldEnabled = obj.optBoolean(KEY_TWO_FINGER_HOLD_ENABLED, false),
twoFingerHoldAction = obj.optString(KEY_TWO_FINGER_HOLD_ACTION, ACTION_MIDDLE_CLICK),
twoFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
twoFingerHoldDelay = obj.optInt(KEY_TWO_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
threeFingerTapEnabled = obj.optBoolean(KEY_THREE_FINGER_TAP_ENABLED, false),
threeFingerTapAction = obj.optString(KEY_THREE_FINGER_TAP_ACTION, ACTION_SHOW_KEYBOARD),
threeFingerDragEnabled = obj.optBoolean(KEY_THREE_FINGER_DRAG_ENABLED, false),
threeFingerDragAction = obj.optString(KEY_THREE_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
threeFingerHoldEnabled = obj.optBoolean(KEY_THREE_FINGER_HOLD_ENABLED, false),
threeFingerHoldAction = obj.optString(KEY_THREE_FINGER_HOLD_ACTION, ACTION_KEY_ESC),
threeFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
threeFingerHoldDelay = obj.optInt(KEY_THREE_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
showGestureDebugOverlay = obj.optBoolean(KEY_SHOW_GESTURE_DEBUG_OVERLAY, false),
gestureThreshold = obj.optInt(KEY_GESTURE_THRESHOLD, DEFAULT_GESTURE_THRESHOLD),
mouseDragMovementMode = normalizeMouseDragMovementMode(
obj.optString(KEY_MOUSE_DRAG_MOVEMENT_MODE, MOUSE_DRAG_MOVEMENT_DIRECT),
),
)
} catch (_: Exception) {
fallbackDefaults()
}
}
private fun normalizeMouseBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
private fun normalizeMouseDragMovementMode(mode: String?): String {
return if (mode == MOUSE_DRAG_MOVEMENT_RELATIVE) {
MOUSE_DRAG_MOVEMENT_RELATIVE
} else {
MOUSE_DRAG_MOVEMENT_DIRECT
}
}
val PAN_ACTIONS = listOf(
PAN_ARROW_KEYS,
PAN_INVERTED_ARROW_KEYS,
PAN_WASD,
PAN_INVERTED_WASD,
PAN_MIDDLE_MOUSE,
PAN_INVERTED_MIDDLE_MOUSE,
PAN_LEFT_CLICK_DRAG,
PAN_RIGHT_CLICK_DRAG,
)
val ZOOM_ACTIONS = listOf(
ZOOM_SCROLL_WHEEL,
ZOOM_PLUS_MINUS,
ZOOM_PAGE_UP_DOWN,
)
@JvmStatic
fun isMouseButtonAction(action: String?): Boolean =
action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK
}
}
@@ -38,6 +38,7 @@ class TouchpadGestures(
private var moverLastX = 0f
private var moverLastY = 0f
private var holdDrag = false
private var scrollLocked = false
private var clicked = 0
private val longPress = Runnable {
held = true
@@ -67,6 +68,7 @@ class TouchpadGestures(
dragging = false
held = false
holdDrag = false
scrollLocked = false
anchorId = -1
moverId = -1
lastTapAt = 0L
@@ -84,7 +86,7 @@ class TouchpadGestures(
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
@@ -117,10 +119,12 @@ class TouchpadGestures(
val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId)
if (ai >= 0 && mi >= 0) {
val mx = event.getX(mi); val my = event.getY(mi)
if (!holdDrag && !scrolled) {
if (!holdDrag && !scrolled && !scrollLocked) {
val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY)
val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY)
if (moverMoved > slopPx && anchorMoved < slopPx) {
if (anchorMoved >= slopPx) {
scrollLocked = true
} else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) {
holdDrag = true
secondWasTap = false
if (!dragging) {
@@ -186,9 +190,9 @@ class TouchpadGestures(
if (gone == moverId) moverId = -1
if (gone == anchorId) {
anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0)
event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
}
event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
click(PointerGestures.BTN_RIGHT)
@@ -212,7 +216,7 @@ class TouchpadGestures(
}
}
dragging = false; held = false; twoFingers = false
holdDrag = false; anchorId = -1; moverId = -1
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
return true
}
}
@@ -225,5 +229,7 @@ class TouchpadGestures(
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
private const val HOLD_DRAG_MOVER_SLOPS = 2f
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
}
}
File diff suppressed because it is too large. Load diff
@@ -47,6 +47,53 @@ object DesktopCatalog {
private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id")
private const val JOURNAL_SUFFIX = ".installing"
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
/** Packages whose extraction over the runtime started and never finished. */
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context))
fun interrupted(root: File): List<Entry> =
root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty()
.sortedBy { it.name }
.mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } }
private val installLock = Any()
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
/** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */
fun pruneDownloads(context: Context) = synchronized(installLock) {
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() }
}
fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
for (entry in interrupted(context)) {
Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}")
install(context, entry, listener)?.let { return it }
}
null
}
internal fun entryJson(e: Entry): JSONObject = JSONObject()
.put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind)
.put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes)
.put("icon", e.icon).put("category", e.category)
internal fun entryFrom(o: JSONObject) = Entry(
o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"),
o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
/** A finished download is kept until its extraction completes, so resuming does not fetch it again. */
private fun downloaded(file: File, entry: Entry): Boolean =
file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() &&
(entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true)
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
@@ -82,22 +129,38 @@ object DesktopCatalog {
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
DesktopRemoval.remove(context, listener)
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
installLocked(context, entry, listener)
}
private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
val download = downloadFile(context, entry)
val journal = journal(root, entry.id)
var keepDownload = false
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
val report = object : LinuxRuntimeInstaller.ProgressListener {
override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent)
override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
RuntimeInstallService.packageProgress(stage, percent)
listener?.onProgress(step, stage, percent)
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
}
try {
if (!downloaded(download, entry)) {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
}
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -112,23 +175,28 @@ object DesktopCatalog {
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
keepDownload = true
marker(context, entry.id).delete()
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, listener)
cleanLegacy(context, report)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
FileUtils.writeString(marker(context, entry.id), entry.version)
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
check(!journal.exists() || journal.delete()) { "Could not finish package installation" }
keepDownload = false
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
if (!keepDownload) download.delete()
release()
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.os.Process;
import android.system.ErrnoException;
@@ -64,14 +65,12 @@ public final class LinuxRuntime {
private static final String HOST_DIR = "opt/android-host";
public static File prootBinary(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot.so");
return new File(rootDir(context), HOST_DIR + "/proot");
}
public static File prootLoader(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot-loader.so");
return new File(rootDir(context), HOST_DIR + "/loader");
}
@@ -187,10 +186,15 @@ public final class LinuxRuntime {
} catch (java.io.IOException e) {
}
}
File fileMax = new File(fakeProc, "file-max");
if (!fileMax.isFile()) {
String[][] seeded = {
{"file-max", "9223372036854775807\n"},
{"overcommit_memory", "0\n"},
};
for (String[] entry : seeded) {
File fake = new File(fakeProc, entry[0]);
if (fake.isFile()) continue;
try {
Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII));
Files.write(fake.toPath(), entry[1].getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
}
}
@@ -205,6 +209,7 @@ public final class LinuxRuntime {
{"overflowuid", "/proc/sys/kernel/overflowuid"},
{"overflowgid", "/proc/sys/kernel/overflowgid"},
{"file-max", "/proc/sys/fs/file-max"},
{"overcommit_memory", "/proc/sys/vm/overcommit_memory"},
};
for (String[] entry : procFiles) {
File fake = new File(fakeProc, entry[0]);
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.util.Log;
@@ -261,6 +262,8 @@ public final class LinuxRuntimeInstaller {
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
if (!AddonPack.PROOT.await(context)) throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
@@ -456,9 +459,12 @@ public final class LinuxRuntimeInstaller {
}
}
// Packages extract over the live runtime and their archives list a link before the file it
// names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -477,29 +483,35 @@ public final class LinuxRuntimeInstaller {
if (parent != null && !parent.isDirectory()) parent.mkdirs();
if (entry.isSymbolicLink()) {
file.delete();
FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath());
links.remove(file);
links.put(file, entry.getLinkName());
} else if (entry.isLink()) {
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
links.remove(file);
File part = partFile(file);
part.delete();
try {
Files.createLink(file.toPath(), target.toPath());
Files.createLink(part.toPath(), target.toPath());
} catch (IOException | UnsupportedOperationException e) {
if (target.isFile()) {
Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
if (part.exists()) place(part, file);
} else if (entry.isFile()) {
try (OutputStream out = new FileOutputStream(file)) {
links.remove(file);
File part = partFile(file);
try (OutputStream out = new FileOutputStream(part)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
}
if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false);
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
place(part, file);
} else {
continue;
}
@@ -507,10 +519,32 @@ public final class LinuxRuntimeInstaller {
listener.onProgress(extracting, -1);
}
}
for (java.util.Map.Entry<File, String> link : links.entrySet()) {
File file = link.getKey();
File part = partFile(file);
try {
Files.deleteIfExists(part.toPath());
Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue()));
place(part, file);
} catch (IOException e) {
Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e);
part.delete();
}
}
return true;
} catch (Exception e) {
Log.e(TAG, "extract", e);
return false;
}
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
private static void place(File part, File file) throws IOException {
java.nio.file.Path path = file.toPath();
if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path);
Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.security.MessageDigest
@@ -11,7 +12,7 @@ object ProotFastPath {
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
LinuxRuntime.prootBinary(context).parentFile == AddonPack.PROOT.dir(context)
fun key(root: File, binds: List<String>): String? {
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
@@ -0,0 +1,34 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
object RuntimeHealth {
private const val MAX_HOPS = 16
fun brokenLibraryLinks(context: Context): List<String> = brokenLibraryLinks(LinuxRuntime.rootDir(context))
/** A complete runtime has no library link in usr/lib whose target is missing. */
fun brokenLibraryLinks(root: File): List<String> {
val lib = File(root, "usr/lib")
val names = lib.list() ?: return emptyList()
return names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
}
}
private fun resolves(root: File, link: Path): Boolean = try {
var path = link
var hops = 0
while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) {
val target = Files.readSymbolicLink(path)
path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target)
}
!Files.isSymbolicLink(path) && Files.exists(path)
} catch (e: Exception) {
false
}
}
@@ -19,6 +19,7 @@ class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
@@ -44,7 +45,27 @@ class RuntimeInstallService : Service() {
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
private fun holdPackage(startId: Int): Int {
startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
while (packageHolds.get() > 0) {
val now = "$packageStage:$packagePercent"
if (now != shown) {
shown = now
manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
}
Thread.sleep(500)
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "package-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int, title: String? = null): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
NotificationManager.IMPORTANCE_LOW).apply {
@@ -58,7 +79,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -72,6 +93,11 @@ class RuntimeInstallService : Service() {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE"
private val packageHolds = java.util.concurrent.atomic.AtomicInteger()
@Volatile private var packageTitle = ""
@Volatile private var packageStage = ""
@Volatile private var packagePercent = -1
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
@@ -91,6 +117,24 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
/** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */
fun holdForPackage(context: Context, title: String): () -> Unit {
packageTitle = title
packageStage = title
packagePercent = -1
packageHolds.incrementAndGet()
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE)
runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) }
.onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") }
val released = java.util.concurrent.atomic.AtomicBoolean()
return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() }
}
fun packageProgress(stage: String, percent: Int) {
packageStage = stage
packagePercent = percent
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
@@ -71,6 +71,9 @@ object UserApps {
private fun menuEntry(context: Context, id: String) =
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
fun launches(program: String?): Boolean =
program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER
fun list(context: Context): List<App> {
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
App(
@@ -30,7 +30,7 @@ object BootMovie {
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
sealed class Import {
data class Done(val name: String, val audioDropped: Boolean) : Import()
data class Done(val name: String, val audioDropped: Boolean, val id: String = "") : Import()
object TooLong : Import()
data class Unsupported(val codec: String?) : Import()
object Failed : Import()
@@ -39,6 +39,21 @@ object BootMovie {
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
private fun customFile(context: Context) = File(dir(context), "custom.webm")
/** A movie imported before movies were kept in Steam's own folder. */
internal fun legacyCustomFile(context: Context) = customFile(context)
/** Unpacks one of DroidDeck's own movies on demand; null for anything else. */
internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) }
/** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */
fun select(context: Context, id: String) {
SessionPrefs.setBootMovie(context, id)
SessionPrefs.setBootMovieApplyNext(context, true)
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
// Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
fun customName(context: Context): String? =
if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null
@@ -46,21 +61,127 @@ object BootMovie {
val chosen = SessionPrefs.bootMovie(context)
return when {
chosen == CUSTOM && customFile(context).isFile -> CUSTOM
isLibraryId(chosen) && BootMovieLibrary.file(context, chosen) != null -> chosen
chosen in ASSETS -> chosen
else -> DROIDDECK_30
}
}
/** The file the current choice plays from, before any volume or length change. */
fun currentFile(context: Context): File? = sourceOf(context, choice(context))
private fun sourceOf(context: Context, choice: String): File? = when {
choice == CUSTOM -> customFile(context)
isLibraryId(choice) -> BootMovieLibrary.file(context, choice)
else -> ASSETS[choice]?.let { extractAsset(context, it) }
}
private fun isLibraryId(id: String) = id.startsWith(BootMovieLibrary.PREFIX) || id.startsWith(BootMovieLibrary.STEAM_PREFIX)
fun prepare(context: Context): File? {
val choice = choice(context)
val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null
if (SessionPrefs.bootMovieSound(context)) return source
val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm")
if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted
if (remux(source, muted, audio = false)) return muted
if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted
Log.e(TAG, "could not strip audio from ${source.name}; not staging it")
return null
BootMovieLibrary.restore(context)
val source = sourceOf(context, choice(context)) ?: return null
return variant(context, source)
}
/** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */
private fun variant(context: Context, source: File): File? {
val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0
val seconds = SessionPrefs.bootMovieSeconds(context)
val folder = File(dir(context), "variants")
val plan = if (isOversized(source)) oversizedPlan(volume) else plan(volume, seconds)
if (plan.passthrough) {
prune(folder, null)
return source
}
folder.mkdirs()
val limitUs = if (seconds > 0) seconds * 1_000_000L else Long.MAX_VALUE
val out = File(folder, BootMovieLibrary.variantPrefix(source) + "v$volume-s$seconds.webm")
if (out.isFile && out.length() > 0L && out.lastModified() >= source.lastModified()) {
prune(folder, out.name)
return out
}
val staged = File(folder, out.name + ".staged")
val ok = runCatching {
plan.steps.any { step ->
when (step) {
Step.REMUX_SILENT -> remux(source, staged, audio = false, limitUs)
Step.REMUX_AUDIO -> remux(source, staged, audio = true, limitUs)
Step.TRANSCODE_SILENT -> transcodeSilent(source, staged, limitUs)
Step.TRANSCODE_AUDIO -> BootMovieTranscoder.transcode(source, staged, limitUs = limitUs, gain = volume / 100f) {} != null
Step.REGAIN -> BootMovieTranscoder.regain(source, staged, volume / 100f, limitUs)
}
}
}.getOrDefault(false)
if (ok && staged.renameTo(out)) {
prune(folder, out.name)
return out
}
staged.delete()
Log.e(TAG, "could not prepare ${source.name} (volume $volume, $seconds s)")
return if (plan.sourceOnFailure) source else null
}
internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN }
/**
* How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and
* whether it still plays as it is when every step fails.
*/
internal class Plan(val passthrough: Boolean, val steps: List<Step>, val sourceOnFailure: Boolean)
internal fun plan(volume: Int, seconds: Int): Plan = when {
volume == 100 && seconds == 0 -> Plan(true, emptyList(), true)
volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false)
// Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder.
volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true)
else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false)
}
/** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */
internal const val MAX_LONG_SIDE = 1920
internal const val MAX_SHORT_SIDE = 1080
internal fun oversized(width: Int, height: Int) = maxOf(width, height) > MAX_LONG_SIDE || minOf(width, height) > MAX_SHORT_SIDE
private fun oversized(format: MediaFormat) =
format.containsKey(MediaFormat.KEY_WIDTH) && format.containsKey(MediaFormat.KEY_HEIGHT) &&
oversized(format.getInteger(MediaFormat.KEY_WIDTH), format.getInteger(MediaFormat.KEY_HEIGHT))
private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true
/** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */
internal fun oversizedPlan(volume: Int): Plan =
Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false)
private fun transcodeSilent(source: File, target: File, limitUs: Long) =
BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null
/** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */
private fun prune(folder: File, keep: String?) {
folder.listFiles()?.forEach { if (it.name != keep) it.delete() }
}
/**
* Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before,
* that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
*/
fun syncFromSteam(context: Context): Boolean {
val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false
if (path == SessionPrefs.bootMovieSteamSeen(context)) return false
SessionPrefs.setBootMovieSteamSeen(context, path)
if (path == SessionFiles.BOOT_MOVIE_PATH) return false
val prefix = "/uioverrides/movies/"
// The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) }
listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path
}
if (BootMovieLibrary.file(context, id) == null) return false
val changed = SessionPrefs.bootMovie(context) != id
BootMovieReport.note(context, "Steam names $path, taken over as $id")
SessionPrefs.setBootMovie(context, id)
return changed
}
private fun extractAsset(context: Context, name: String): File? {
@@ -82,11 +203,15 @@ object BootMovie {
}
}
fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import {
fun import(
context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {},
title: String? = null, source: String = BootMovieLibrary.SOURCE_IMPORT, repoId: String? = null,
cover: ByteArray? = null, use: Boolean = true,
): Import {
val (format, audio) = probe(file) ?: return Import.Unsupported(null)
val video = format.getString(MediaFormat.KEY_MIME)
val audioPlayable = audio == null || audio in AUDIO_CODECS
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES && !oversized(format)
val copy = playable && audioPlayable && isWebm(file)
val remux = !copy && playable && video in REMUX_CODECS
if (!copy && !remux) {
@@ -95,11 +220,12 @@ object BootMovie {
if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong
onStart()
}
val target = customFile(context)
val staged = File(dir(context), "custom.webm.staged")
val shown = title?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension
val library = BootMovieLibrary.libraryDir(context)
val staged = File(library, ".import-${System.nanoTime()}.staged")
var audioDropped = remux && !audioPlayable
return try {
target.parentFile?.mkdirs()
library.mkdirs()
val ok = when {
copy -> {
file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } }
@@ -108,11 +234,10 @@ object BootMovie {
remux -> remux(file, staged, audio = true)
else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null
}
if (!ok || !staged.renameTo(target)) return Import.Failed
File(target.parentFile, "custom-muted.webm").delete()
SessionPrefs.setBootMovieCustomName(context, file.name)
SessionPrefs.setBootMovie(context, CUSTOM)
Import.Done(file.name, audioDropped)
if (!ok || !staged.setReadable(true, false)) return Import.Failed
val name = BootMovieLibrary.commit(context, staged, shown, source, repoId, cover) ?: return Import.Failed
if (use) select(context, BootMovieLibrary.idOf(name))
Import.Done(shown, audioDropped, BootMovieLibrary.idOf(name))
} catch (e: Exception) {
Log.w(TAG, "could not import ${file.name}", e)
Import.Failed
@@ -124,8 +249,9 @@ object BootMovie {
fun removeCustom(context: Context) {
customFile(context).delete()
File(dir(context), "custom-muted.webm").delete()
File(dir(context), "variants").listFiles { f -> f.name.startsWith(BootMovieLibrary.variantPrefix(customFile(context))) }?.forEach { it.delete() }
SessionPrefs.setBootMovieCustomName(context, null)
if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30)
if (SessionPrefs.bootMovie(context) == CUSTOM) select(context, DROIDDECK_30)
}
private fun isWebm(file: File): Boolean {
@@ -156,7 +282,7 @@ object BootMovie {
}
}
private fun remux(source: File, target: File, audio: Boolean): Boolean {
private fun remux(source: File, target: File, audio: Boolean, limitUs: Long = Long.MAX_VALUE): Boolean {
val staged = File(target.parentFile, target.name + ".remux")
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
@@ -183,7 +309,7 @@ object BootMovie {
val info = MediaCodec.BufferInfo()
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0) break
if (size < 0 || extractor.sampleTime > limitUs) break
val track = tracks[extractor.sampleTrackIndex]
if (track != null) {
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
@@ -0,0 +1,410 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.media.MediaMetadataRetriever
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
/**
* The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in
* Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own
* three startup movies and the movies bought in Steam's points shop (neither can be removed).
*/
object BootMovieLibrary {
private const val TAG = "BootMovieLibrary"
const val PREFIX = "file:"
/** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:<path Steam names>`. */
const val STEAM_PREFIX = "steam:"
/** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */
private val lock = Any()
/** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */
internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm")
const val SOURCE_DROIDDECK = "droiddeck"
const val SOURCE_IMPORT = "import"
const val SOURCE_REPO = "repo"
const val SOURCE_STEAM = "steam"
const val SOURCE_POINTSHOP = "pointshop"
class Entry(val id: String, val title: String, val source: String, val file: File?, val removable: Boolean)
internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies")
/**
* The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the
* x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file.
*/
internal fun customMovieDirs(context: Context): List<File> =
listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path }
/** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */
internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library")
private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json")
internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
/** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */
internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf")
private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs")
fun idOf(name: String) = PREFIX + name
/** Every Steam client folder: the usual one and the x86-64 client's. */
internal fun steamRoots(context: Context): List<File> = listOf(
steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
).filter { it.isDirectory }
private fun steamFile(context: Context, id: String): File? {
val path = id.removePrefix(STEAM_PREFIX)
if (!path.startsWith("/") || path.contains("..") || path.contains('\\') || !path.endsWith(".webm", ignoreCase = true)) return null
return steamRoots(context).firstNotNullOfOrNull { root ->
listOf("config", "steamui", "").map { File(root, it + path) }.firstOrNull { it.isFile && it.length() > 0L }
}
}
fun file(context: Context, id: String): File? {
if (id.startsWith(STEAM_PREFIX)) return steamFile(context, id)
if (!id.startsWith(PREFIX)) return null
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return null
return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L }
}
private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) &&
file.name !in STAGED && !file.name.startsWith(".")
private fun readMeta(context: Context): JSONObject =
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
/** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */
private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) {
val meta = readMeta(context)
if (change(meta)) {
val file = metaFile(context)
file.parentFile?.mkdirs()
val staged = File(file.parentFile, file.name + ".staged")
runCatching {
staged.writeText(meta.toString())
if (!staged.renameTo(file)) staged.delete()
}.onFailure { staged.delete(); Log.w(TAG, "could not write the movie list", it) }
}
}
/** The post a movie was installed from, if it came from SteamDeckRepo. */
fun repoId(context: Context, name: String): String? =
readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() }
fun installedRepoIds(context: Context): Set<String> {
val meta = readMeta(context)
return meta.keys().asSequence().filter { file(context, idOf(it)) != null }
.mapNotNull { meta.optJSONObject(it)?.optString("repo")?.takeIf { id -> id.isNotEmpty() } }.toSet()
}
fun nameForRepo(context: Context, repoId: String): String? {
val meta = readMeta(context)
return meta.keys().asSequence().firstOrNull { file(context, idOf(it)) != null && meta.optJSONObject(it)?.optString("repo") == repoId }
}
fun list(context: Context): List<Entry> {
val out = ArrayList<Entry>()
out.add(Entry(BootMovie.DROIDDECK_30, "DroidDeck · 30 fps", SOURCE_DROIDDECK, null, removable = false))
out.add(Entry(BootMovie.DROIDDECK_60, "DroidDeck · 60 fps", SOURCE_DROIDDECK, null, removable = false))
BootMovie.customName(context)?.let { out.add(Entry(BootMovie.CUSTOM, it, SOURCE_IMPORT, BootMovie.legacyCustomFile(context), removable = true)) }
restore(context)
val meta = readMeta(context)
val names = HashSet<String>()
customMovieDirs(context).flatMap { dir -> dir.listFiles { f -> isMovie(f) }.orEmpty().toList() }
.filter { names.add(it.name) }.sortedBy { it.name.lowercase() }.forEach { f ->
val info = meta.optJSONObject(f.name)
val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM
val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension
// Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand).
out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true))
}
val steam = LinkedHashMap<String, File>()
SessionFiles.startupMoviePath(steamConfig(context))?.takeIf { !it.startsWith(OVERRIDES) }?.let { path ->
steamFile(context, STEAM_PREFIX + path)?.let { steam[STEAM_PREFIX + path] = it }
}
val bought = HashMap<String, String>()
for (root in steamRoots(context)) {
File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
.sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) }
val scan = pointShopMovies(root)
bought.putAll(scan.titles)
for (found in scan.files) steam.putIfAbsent(STEAM_PREFIX + "/" + found.relativeTo(root).invariantSeparatorsPath, found)
}
val seen = HashSet<String>()
for ((id, file) in steam) {
if (file.name in STAGED || !seen.add(file.canonicalPath)) continue
val native = file.nameWithoutExtension.lowercase() in NATIVE
// Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
}
return out
}
/** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup")
private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache")
private val scanLock = Any()
private class Scan(val at: Long, val files: List<File>, val titles: Map<String, String>)
private val scans = HashMap<String, Scan>()
/** Forgets the last search for Steam's own movies. */
internal fun forgetScan() = synchronized(scanLock) { scans.clear() }
/**
* Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the
* client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder.
*/
private fun pointShopMovies(root: File): Scan = synchronized(scanLock) {
val now = System.currentTimeMillis()
scans[root.path]?.let { if (now - it.at < 15_000L) return it }
val found = listOf("config", "userdata", "appcache", "steamui").flatMap { top ->
val base = File(root, top)
if (!base.isDirectory) return@flatMap emptyList()
base.walkTopDown().maxDepth(5)
.onEnter { dir -> dir.name !in skippedFolders && !(top == "steamui" && dir.parentFile == base && dir.name == "movies") }
.take(20_000).filter { isMovie(it) }.toList()
}.sortedBy { it.path.lowercase() }
val titles = HashMap<String, String>()
for (file in found) boughtId(file)?.let { id -> boughtTitle(root, id)?.let { titles[file.path] = it } }
val scan = Scan(now, found, titles)
scans[root.path] = scan
scan
}
/** The item number in the name Steam gives a bought movie, `<item number>_<hash>.webm`; null for any other name. */
private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1)
/** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */
private fun boughtTitle(root: File, id: String): String? {
val key = "communityitemid\\\":\\\"$id\\\""
val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)")
for (user in File(root, "userdata").listFiles { f -> f.isDirectory }.orEmpty()) {
val text = runCatching { File(user, "config/localconfig.vdf").readText() }.getOrNull() ?: continue
val at = text.indexOf(key)
if (at < 0) continue
title.find(text.substring(at, (at + 3000).coerceAtMost(text.length)))?.groupValues?.get(1)?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private const val OVERRIDES = "/uioverrides/movies/"
/** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */
internal fun restore(context: Context) = synchronized(lock) {
migrateLegacyMeta(context)
val meta = readMeta(context)
val library = libraryDir(context)
meta.keys().asSequence().toList().forEach { name ->
val kept = File(library, name)
if (name.contains('/') || !kept.isFile || kept.length() == 0L) return@forEach
val copy = File(moviesDir(context), name)
if (!copy.isFile || copy.length() != kept.length()) mirror(kept, copy)
}
}
/** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */
private fun migrateLegacyMeta(context: Context) {
val legacy = File(moviesDir(context), ".droiddeck-movies.json")
if (!legacy.isFile) return
val old = runCatching { JSONObject(legacy.readText()) }.getOrNull() ?: return
editMeta(context) { meta ->
var changed = false
old.keys().asSequence().toList().forEach { name ->
val info = old.optJSONObject(name) ?: return@forEach
if (meta.optJSONObject(name) == null && File(moviesDir(context), name).isFile) { meta.put(name, info); changed = true }
}
changed
}
legacy.delete()
}
/** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */
private fun mirror(kept: File, copy: File): Boolean {
val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged")
return try {
copy.parentFile?.mkdirs()
kept.inputStream().use { input -> staged.outputStream().use { output -> input.copyTo(output) } }
staged.setReadable(true, false) && staged.renameTo(copy)
} catch (e: Exception) {
Log.w(TAG, "could not copy ${kept.name} into Steam's movies", e)
false
} finally {
staged.delete()
}
}
fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id
/** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */
fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id)
/** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */
fun freeName(context: Context, title: String): String {
val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" }
val dirs = customMovieDirs(context)
val library = libraryDir(context)
var name = "$base.webm"
var n = 2
while (dirs.any { File(it, name).exists() } || File(library, name).exists() || name in STAGED) name = "$base ($n).webm".also { n++ }
return name
}
/**
* Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its
* title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time.
*/
internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? =
synchronized(lock) {
val name = freeName(context, title)
val kept = File(libraryDir(context), name)
if (!staged.renameTo(kept)) return null
if (!mirror(kept, File(moviesDir(context), name))) { kept.delete(); return null }
register(context, name, title, source, repoId, cover)
name
}
/** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */
fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) {
editMeta(context) { meta ->
val info = JSONObject().put("title", title).put("source", source)
if (repoId != null) info.put("repo", repoId)
meta.put(name, info)
true
}
if (cover != null) runCatching {
thumbsDir(context).mkdirs()
File(thumbsDir(context), coverName(name)).writeBytes(cover)
}
}
fun remove(context: Context, id: String): Boolean {
val removed = when {
id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true }
else -> {
// Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone.
if (!id.startsWith(PREFIX)) return false
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false
val copies = customMovieDirs(context).map { File(it, name) }
// A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted.
val target = copies.firstOrNull { it.exists() }
val managed = File(libraryDir(context), name)
if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false
synchronized(lock) {
val main = File(moviesDir(context), name)
val another = copies.any { it != target && it.exists() }
val ownsRecord = target == null || target.path == main.path || (!another && !managed.isFile)
var gone = target == null || target.delete()
if (gone && ownsRecord) {
gone = !managed.exists() || managed.delete()
if (gone) {
editMeta(context) { it.remove(name) != null }
File(thumbsDir(context), coverName(name)).delete()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
}
}
if (gone) {
val prefixes = listOfNotNull(target, main.takeIf { ownsRecord }).map { variantPrefix(it) }
File(context.filesDir, "boot-movie/variants").listFiles { f -> prefixes.any { f.name.startsWith(it) } }?.forEach { it.delete() }
}
gone
}
}
}
if (removed && SessionPrefs.bootMovie(context) == id) BootMovie.select(context, BootMovie.DROIDDECK_30)
return removed
}
/** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */
internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-"
private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
private fun coverName(name: String) = "cover-" + safe(name)
/** How long a movie runs, in whole seconds; 0 when it cannot be read. */
fun durationSeconds(file: File?): Int {
if (file == null || !file.isFile) return 0
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
((ms + 500) / 1000).toInt()
} catch (e: Exception) {
0
} finally {
runCatching { retriever.release() }
}
}
/** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */
private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? {
var best: Bitmap? = null
var bestLuma = -1
for (fraction in doubleArrayOf(0.33, 0.5, 0.66, 0.2, 0.8, 0.1, 0.9)) {
val frame = runCatching { retriever.getFrameAtTime((durationMs * 1000L * fraction).toLong(), MediaMetadataRetriever.OPTION_CLOSEST) }.getOrNull() ?: continue
val luma = averageLuma(frame)
if (luma >= MIN_COVER_LUMA) return frame
if (luma > bestLuma) { best = frame; bestLuma = luma }
}
return best ?: runCatching { retriever.frameAtTime }.getOrNull()
}
private fun averageLuma(bitmap: Bitmap): Int {
val small = Bitmap.createScaledBitmap(bitmap, 16, 9, true)
var sum = 0L
for (y in 0 until small.height) for (x in 0 until small.width) {
val c = small.getPixel(x, y)
sum += (((c shr 16) and 0xFF) * 3 + ((c shr 8) and 0xFF) * 6 + (c and 0xFF)) / 10
}
return (sum / (small.width * small.height)).toInt()
}
private const val MIN_COVER_LUMA = 24
/** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */
fun cover(context: Context, entry: Entry): File? {
val file = fileOf(context, entry) ?: return null
val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id
// Points Shop covers once kept a picture that was not always the movie's own; those files are not used.
val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name))
if (given.isFile && given.length() > 0L) return given
val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg"
val frame = File(thumbsDir(context), key)
if (frame.isFile && frame.length() > 0L) return frame
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
val bitmap = brightestFrame(retriever, ms) ?: return null
val width = 480
val scaled = if (bitmap.width > width) Bitmap.createScaledBitmap(bitmap, width, (bitmap.height * width / bitmap.width).coerceAtLeast(1), true) else bitmap
thumbsDir(context).mkdirs()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
val staged = File(thumbsDir(context), "$key.staged")
staged.outputStream().use { scaled.compress(CompressFormat.JPEG, 82, it) }
if (staged.renameTo(frame)) frame else null
} catch (e: Exception) {
Log.w(TAG, "no cover for ${file.name}", e)
null
} finally {
runCatching { retriever.release() }
}
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and
* where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config.
*/
object BootMovieReport {
private const val TAG = "BootMovieReport"
private const val MAX_TRACE_LINES = 200
private const val MAX_HITS = 60
private val KNOWN_TITLES = listOf("Turret Factory")
private val SKIPPED = setOf("steamapps", "compatdata", "shadercache", "logs")
private val BINARY = setOf("webm", "jpg", "jpeg", "png", "ico", "mp4", "ogg", "mp3", "wav", "ttf", "woff", "woff2", "gif", "webp", "bin", "so", "dll", "exe", "zip", "pak")
private val lock = Any()
private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log")
/** Adds a line to the short trace kept for the report; only the latest lines are kept. */
fun note(context: Context, line: String) {
synchronized(lock) {
runCatching {
val file = traceFile(context)
file.parentFile?.mkdirs()
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
val lines = (if (file.isFile) file.readLines() else emptyList()) + "$stamp $line"
file.writeText(lines.takeLast(MAX_TRACE_LINES).joinToString("\n") + "\n")
}
}
}
/** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */
fun write(context: Context): File? {
val text = runCatching { build(context) }.getOrElse {
Log.w(TAG, "could not build the report", it)
return null
}
val folders = listOfNotNull(
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck"),
context.getExternalFilesDir(null),
)
for (folder in folders) {
val file = File(folder, "boot-movie-report.txt")
val saved = runCatching { folder.mkdirs(); file.writeText(text); true }.getOrDefault(false)
if (saved) return file
Log.w(TAG, "could not write the report to ${file.path}")
}
return null
}
private fun describe(file: File) =
"${file.path} ${file.length()} bytes ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(file.lastModified()))}"
internal fun build(context: Context): String {
val out = StringBuilder()
fun line(text: String = "") { out.append(text).append('\n') }
fun listing(title: String, folder: File) {
line("-- $title: ${folder.path}")
val files = folder.listFiles()?.sortedBy { it.name.lowercase() }
if (files == null) line(" (missing)") else if (files.isEmpty()) line(" (empty)")
else files.forEach { line(" " + describe(it)) }
}
line("DroidDeck boot movie report, ${Date()}")
line()
line("== DroidDeck state")
line("choice=${BootMovie.choice(context)} saved choice=${SessionPrefs.bootMovie(context)} pending pick=${SessionPrefs.bootMovieApplyNext(context)}")
line("last Steam path seen=${SessionPrefs.bootMovieSteamSeen(context)}")
line("sound=${SessionPrefs.bootMovieSound(context)} volume=${SessionPrefs.bootMovieVolume(context)} seconds=${SessionPrefs.bootMovieSeconds(context)}")
line("session running=${SessionState.running}")
line()
line("== The movie list as shown")
BootMovieLibrary.list(context).forEach { line("${it.id} title=${it.title} source=${it.source} removable=${it.removable} file=${it.file?.path}") }
line()
line("== DroidDeck folders")
listing("library", BootMovieLibrary.libraryDir(context))
listing("processed copies", File(context.filesDir, "boot-movie/variants"))
val meta = File(context.filesDir, "boot-movie/movies.json")
line("-- movie list file: ${if (meta.isFile) meta.readText().take(8000) else "(missing)"}")
val roots = BootMovieLibrary.steamRoots(context)
val movieIds = HashSet<String>()
for (root in roots) {
line()
line("== Steam folder ${root.path}")
val config = File(root, "config/config.vdf")
line("config.vdf: ${if (config.isFile) describe(config) else "(missing)"}")
val text = runCatching { config.readText() }.getOrNull()
if (text != null) {
line("-- StartupMovie block:")
(SessionFiles.startupMovieBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- Customization section with the lines before it:")
(SessionFiles.customizationBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- every line of config.vdf that mentions a movie:")
text.lines().filter { it.contains("movie", ignoreCase = true) }.take(40).forEach { line(" ${it.trim()}") }
Regex("\"MovieID\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)?.takeIf { it.isNotEmpty() && it != "0" }?.let { movieIds.add(it) }
}
listing("custom movies", File(root, "config/uioverrides/movies"))
listing("Steam's own movies", File(root, "steamui/movies"))
listing("config/movies", File(root, "config/movies"))
line("-- every .webm below config, userdata, appcache and steamui:")
var shown = 0
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || !file.name.endsWith(".webm", ignoreCase = true)) continue
if (shown++ >= 300) break
line(" " + describe(file))
Regex("^(\\d+)_[0-9a-fA-F]+").find(file.name)?.groupValues?.get(1)?.let { movieIds.add(it) }
}
}
}
line()
line("== Where Steam keeps movie names (text found in Steam's files)")
val needles = (KNOWN_TITLES + movieIds).distinct()
line("looking for: ${needles.joinToString(", ")}")
search(roots, needles) { line(" $it") }
line()
line("== What each session start did to Steam's config (latest last)")
val trace = traceFile(context)
if (trace.isFile) trace.readLines().forEach { line(" $it") } else line(" (nothing yet: start a session after picking a movie)")
return out.toString()
}
private fun search(roots: List<File>, needles: List<String>, report: (String) -> Unit) {
if (needles.isEmpty()) return
val deadline = System.currentTimeMillis() + 25_000L
var scanned = 0
var hits = 0
outer@ for (root in roots) {
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || file.length() > (2L shl 20) || file.extension.lowercase() in BINARY) continue
if (++scanned > 20_000 || System.currentTimeMillis() > deadline || hits >= MAX_HITS) break@outer
val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: continue
for (needle in needles) {
val at = text.indexOf(needle)
if (at < 0) continue
hits++
val around = text.substring((at - 60).coerceAtLeast(0), (at + needle.length + 100).coerceAtMost(text.length))
report("$needle in ${file.path}: " + around.map { if (it in ' '..'~') it else '.' }.joinToString(""))
}
}
}
}
report("(searched $scanned files, $hits matches)")
}
}
@@ -37,15 +37,77 @@ internal object BootMovieTranscoder {
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null
}.getOrDefault(false)
fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? {
fun transcode(source: File, target: File, keepAudio: Boolean = true, limitUs: Long = Long.MAX_VALUE, gain: Float = 1f, onProgress: (Int) -> Unit): Result? {
val hadAudio = keepAudio && trackFormat(source, "audio/") != null
val audio = if (hadAudio) runCatching { encodeAudio(source) }
val audio = if (hadAudio) runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null
val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) }
val muxedAudio = runCatching { encodeVideo(source, target, audio, limitUs, onProgress) }
.onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null
return Result(audioDropped = hadAudio && !muxedAudio)
}
/** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */
fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean {
val audio = runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
val staged = File(target.parentFile, target.name + ".regain")
return try {
extractor.setDataSource(source.path)
var videoTrack = -1
var videoFormat: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (videoTrack < 0 && (mime == MediaFormat.MIMETYPE_VIDEO_VP8 || mime == MediaFormat.MIMETYPE_VIDEO_VP9)) {
videoTrack = i
videoFormat = format
}
}
if (videoTrack < 0 || videoFormat == null) return false
extractor.selectTrack(videoTrack)
val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
val outVideo = mux.addTrack(videoFormat)
val outAudio = mux.addTrack(audio.format)
mux.start()
val info = MediaCodec.BufferInfo()
var audioIndex = 0
fun writeAudioUpTo(timeUs: Long) {
while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) {
val sample = audio.samples[audioIndex++]
info.set(0, sample.data.size, sample.timeUs, 0)
mux.writeSampleData(outAudio, ByteBuffer.wrap(sample.data), info)
}
}
var bufferSize = 8 shl 20
if (videoFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
val buffer = ByteBuffer.allocate(bufferSize)
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0 || extractor.sampleTime > limitUs) break
val time = extractor.sampleTime
writeAudioUpTo(time)
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
info.set(0, size, time, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0)
mux.writeSampleData(outVideo, buffer, info)
extractor.advance()
}
writeAudioUpTo(Long.MAX_VALUE)
mux.stop()
mux.release()
muxer = null
staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not change the volume of ${source.name}", e)
false
} finally {
runCatching { muxer?.release() }
extractor.release()
staged.delete()
}
}
private fun trackFormat(source: File, prefix: String): Pair<Int, MediaFormat>? {
val extractor = MediaExtractor()
return try {
@@ -75,6 +137,14 @@ internal object BootMovieTranscoder {
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
}
/** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */
internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray {
val frames = pcm.size / channels
val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt()
if (gain == 1f && kept == frames) return pcm
return ShortArray(kept * channels) { (pcm[it] * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() }
}
internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray {
if (rate == OPUS_RATE) return pcm
val frames = pcm.size / channels
@@ -94,7 +164,10 @@ internal object BootMovieTranscoder {
return out
}
private fun encodeAudio(source: File): Audio? {
/** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */
private const val MAX_PCM_BYTES = 64 shl 20
private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? {
val (track, inFormat) = trackFormat(source, "audio/") ?: return null
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
@@ -139,13 +212,16 @@ internal object BootMovieTranscoder {
}
dec.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
// Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped.
if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break
if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break }
}
}
if (channels !in 1..2) return null
val raw = pcm.toByteArray()
val shorts = ShortArray(raw.size / 2)
ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts)
val samples = resample(shorts, channels, rate)
val samples = scale(resample(shorts, channels, rate), channels, gain, limitUs)
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels)
format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
@@ -201,7 +277,7 @@ internal object BootMovieTranscoder {
private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else
runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() }
private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean {
private fun encodeVideo(source: File, target: File, audio: Audio?, limitUs: Long, onProgress: (Int) -> Unit): Boolean {
val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track")
val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360)
val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation)
@@ -278,7 +354,8 @@ internal object BootMovieTranscoder {
val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US)
if (index >= 0) {
val timeUs = decInfo.presentationTimeUs
val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
val pastLimit = timeUs > limitUs
val keep = decInfo.size > 0 && !pastLimit && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
dec.releaseOutputBuffer(index, keep)
if (keep) {
if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out")
@@ -290,9 +367,10 @@ internal object BootMovieTranscoder {
if (percent != lastProgress) { lastProgress = percent; onProgress(percent) }
}
}
if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
if (pastLimit || decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
enc.signalEndOfInputStream()
decoderDone = true
inputDone = true
}
}
}
@@ -1,10 +1,12 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LegacyNames
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -15,6 +17,7 @@ object SessionFiles {
private const val TAG = "SessionFiles"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
private const val STAGED_LEDGER = "usr/local/.droiddeck-staged"
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
internal val DESKTOP_FILES = arrayOf(
@@ -27,7 +30,8 @@ object SessionFiles {
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
)
fun stage(context: Context, root: File) {
/** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */
fun stage(context: Context, root: File, startsSession: Boolean = false) {
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
@@ -134,15 +138,15 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + listOf("xodus-cli", "xodus-service", "LICENSE.xodus", "LICENSE.nexus", "LICENSE.LGPL-2.1", "LICENSE.nghttp2", "backend.lock.json", "runtime.lock.json", "version",
"windows/xgameruntime.dll", "windows/winhttp.dll", "windows/windows.web.dll", "windows/twinapi.appcore.dll", "windows/libnghttp2.dll")
.map { "usr/local/lib/droiddeck-xbox/$it" to "usr/local/lib/droiddeck-xbox/$it" } + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
val ledger = StageLedger(File(root, STAGED_LEDGER), apkIdentity(context))
for ((asset, relative) in all) {
val target = File(root, relative)
if (ledger.current(relative, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
@@ -156,7 +160,7 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "$relative NOT staged")
if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged")
}
File(root, "usr/local/bin/bannerlator-steam-compat").delete()
LegacyNames.migrateRootfs(root)
@@ -168,13 +172,17 @@ object SessionFiles {
"i386-windows/winedirectaudio.drv",
"version.txt",
)
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
val directAudioPack = AddonPack.DIRECTAUDIO.takeIf { it.installed(context) }?.dir(context)
if (directAudioPack == null) Log.w(TAG, "DirectAudio pack is not installed; drivers not staged")
if (directAudioPack != null) for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val key = "$DIRECTAUDIO_DIR/$set/lib/wine/$relative"
val target = File(root, key)
if (ledger.current(key, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("directaudio/$set/$relative").use { input ->
File(directAudioPack, "$set/$relative").inputStream().use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(target)
@@ -183,8 +191,9 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
if (installed) ledger.staged(key, target) else Log.e(TAG, "DirectAudio $set/$relative NOT staged")
}
ledger.save()
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
val preload = StringBuilder("/usr/local/lib/libblsession.so\n")
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
@@ -201,20 +210,118 @@ object SessionFiles {
if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) {
Log.e(TAG, "could not write steam-compatibility-label")
}
val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() }
val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged")
if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n")
|| !stagedSocket.renameTo(File(droiddeckEtc, "bwrap-socket"))) {
stagedSocket.delete()
Log.e(TAG, "could not write bwrap-socket")
}
BootMovie.syncFromSteam(context)
val startupMovie = BootMovie.prepare(context)
// The x86-64 client's config is left alone until its first start creates it.
val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam"))
.filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory }
for (steam in steamRoots) {
val startupMovieDir = File(steam, "config/uioverrides/movies")
val bigPictureMovieInstalled = startupMovie != null &&
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(steam, "config/config.vdf"))
val force = SessionPrefs.bootMovieApplyNext(context)
val steamHomes = listOf(File(root, "root/.local/share/Steam")) +
listOf(File(root, "root/.droiddeck-x64/.local/share/Steam")).filter { File(it, "config").isDirectory }
var allDone = true
var allInstalled = true
var seen: String? = null
val activeIsX64 = SessionPrefs.activeSteamClientArch(context) == "x86_64"
for (home in steamHomes) {
val movieDir = File(home, "config/uioverrides/movies")
val config = File(home, "config/config.vdf")
if (startupMovie == null) {
// Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either.
for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) {
File(movieDir, name).delete()
File(movieDir, ".$name.source").delete()
}
}
val bigPictureMovieInstalled = startupMovie != null && stageStartupMovie(startupMovie, movieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, movieDir, "steam_os_startup.webm")
val before = startupMoviePath(config)
val done = if (bigPictureMovieInstalled) ensureStartupMovieDefault(config, force) else resetMissingStartupMovie(config, force)
BootMovieReport.note(
context,
"stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " +
"staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}",
)
if (!done) allDone = false
if (!bigPictureMovieInstalled) allInstalled = false
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
}
// A pick made in the app is done once a session started with every Steam client's config having it; guest commands run
// before that, and a Steam that is already running writes its own choice back when it exits.
if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false)
seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
/**
* Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that
* could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config
* no longer names a missing movie.
*/
internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean {
if (!config.isFile) return true
val text = runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config to reset the startup movie", it)
return false
}
val movie = activeStartupMovie(text) ?: return true
val block = text.substring(movie.keyStart, movie.close + 1)
val path = scalar(block, "LocalPath").orEmpty()
if (path.isEmpty()) return true
val missing = File(config.parentFile, "uioverrides/movies/" + path.removePrefix(BOOT_MOVIES_DIR))
val gone = path.startsWith(BOOT_MOVIES_DIR) && !missing.isFile && (force || path == BOOT_MOVIE_PATH)
if (!gone) return false
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val updated = setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", "", newline)
val staged = File(config.parentFile, config.name + ".staged")
return try {
staged.writeText(text.replaceRange(movie.keyStart, movie.close + 1, updated))
(staged.renameTo(config)).also { if (!it) staged.delete() }
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not reset Steam startup movie selection", e)
false
}
}
private fun apkIdentity(context: Context): String {
val apk = File(context.applicationInfo.sourceDir)
return "${apk.path} ${apk.length()} ${apk.lastModified()}"
}
private class StageLedger(private val file: File, private val identity: String) {
private val before: Map<String, String> = FileUtils.readString(file)?.lines()
?.takeIf { it.firstOrNull() == identity }
?.drop(1)?.mapNotNull { line -> line.split('\t').takeIf { it.size == 2 }?.let { it[0] to it[1] } }?.toMap()
.orEmpty()
private val after = LinkedHashMap<String, String>()
fun current(relative: String, target: File): Boolean {
val mark = mark(target) ?: return false
if (before[relative] != mark) return false
after[relative] = mark
return true
}
fun staged(relative: String, target: File) {
mark(target)?.let { after[relative] = it }
}
fun save() {
if (after == before) return
val text = (listOf(identity) + after.map { (relative, mark) -> "$relative\t$mark" }).joinToString("\n")
val staged = File(file.parentFile, file.name + ".staged")
if (!FileUtils.writeString(staged, text) || !staged.renameTo(file)) {
staged.delete()
file.delete()
}
}
private fun mark(target: File): String? =
if (target.isFile) "${target.length()} ${target.lastModified()} ${target.canExecute()}" else null
}
private fun stageStartupMovie(source: File, directory: File, name: String): Boolean {
@@ -230,7 +337,11 @@ object SessionFiles {
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(movie)
if (installed) FileUtils.writeString(stamp, identity)
if (installed) {
FileUtils.writeString(stamp, identity)
// Steam's picker draws its preview from this picture and does not redraw it when the movie changes.
File(directory, name.removeSuffix(".webm") + ".jpg").delete()
}
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie $name", e)
} finally {
@@ -242,43 +353,108 @@ object SessionFiles {
private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
private fun ensureStartupMovieDefault(config: File) {
val path = "/uioverrides/movies/bigpicture_startup.webm"
internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/"
internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm"
/**
* Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam
* settings is where older clients kept it, and DroidDeck still keeps it in step.
*/
private fun customizationMovie(text: String): VdfBlock? {
var from = 0
while (true) {
val customization = findObject(text, "Customization", from, text.length) ?: return null
findObject(text, "StartupMovie", customization.open + 1, customization.close)?.let { return it }
from = customization.close + 1
}
}
/** The older `StartupMovie` section: the first one that is not inside `Customization`. */
private fun olderMovie(text: String): VdfBlock? {
val inside = customizationMovie(text)
var from = 0
while (true) {
val movie = findObject(text, "StartupMovie", from, text.length) ?: return null
if (inside == null || movie.keyStart != inside.keyStart) return movie
from = movie.close + 1
}
}
/** The section Steam reads: the one under `Customization` when there is one. */
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
/** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
internal fun startupMoviePath(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val movie = activeStartupMovie(text) ?: return null
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() }
}
/** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
internal fun startupMovieBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val parts = listOfNotNull(
customizationMovie(text)?.let { "under Customization:\n" + text.substring(it.keyStart, it.close + 1) },
olderMovie(text)?.let { "older place:\n" + text.substring(it.keyStart, it.close + 1) },
)
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
/** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */
internal fun customizationBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val customization = findObject(text, "Customization", 0, text.length) ?: return null
val before = text.substring(0, customization.keyStart).lines().takeLast(4).joinToString("\n")
return (before + "\n" + text.substring(customization.keyStart, customization.close + 1)).take(4000)
}
private fun pointAt(text: String, movie: VdfBlock, path: String, newline: String): String {
val block = text.substring(movie.keyStart, movie.close + 1)
return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline))
}
/** True when Steam's config selects DroidDeck's staged movie once this returns. */
internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean {
val path = BOOT_MOVIE_PATH
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config for startup movie", it)
return
return false
} else ""
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val movie = findObject(text, "StartupMovie", 0, text.length)
if (movie != null) {
val block = text.substring(movie.keyStart, movie.close + 1)
val custom = customizationMovie(text)
val active = custom ?: olderMovie(text)
if (active != null) {
val block = text.substring(active.keyStart, active.close + 1)
val selectedId = scalar(block, "MovieID")
val selectedPath = scalar(block, "LocalPath")
if (selectedPath == path) {
if (selectedPath == path && custom != null) {
Log.i(TAG, "Steam startup movie is set to DroidDeck")
return
return true
}
val legacyDefault = selectedId == "0" && selectedPath == legacyPath
val hasExplicitSelection =
(!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()
if (!legacyDefault && hasExplicitSelection) {
if (!force && !legacyDefault && hasExplicitSelection && selectedPath != path) {
Log.i(TAG, "Steam startup movie selection preserved")
return
return false
}
val updated = setScalar(block, "MovieID", "0", newline)
.let { setScalar(it, "LocalPath", path, newline) }
text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
} else {
}
// Later sections first, so the positions of earlier ones stay valid.
val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart }
for (target in targets) text = pointAt(text, target, path, newline)
if (olderMovie(text) == null) {
val steam = findSteamBlock(text)
if (steam == null) {
if (text.isNotBlank()) {
if (text.isNotBlank() && customizationMovie(text) == null) {
Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
return
return false
}
if (text.isBlank()) {
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
}
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
} else {
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
@@ -286,22 +462,35 @@ object SessionFiles {
text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
}
}
if (customizationMovie(text) == null) text = withCustomization(text, path, newline)
val staged = File(config.parentFile, config.name + ".staged")
try {
return try {
config.parentFile?.mkdirs()
staged.writeText(text)
if (!staged.renameTo(config)) {
staged.delete()
Log.e(TAG, "could not write Steam startup movie selection")
false
} else {
Log.i(TAG, "Steam startup movie default selected")
true
}
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write Steam startup movie selection", e)
false
}
}
/** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */
private fun withCustomization(text: String, path: String, newline: String): String {
val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text
val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, root.close).takeWhile { it == '\t' || it == ' ' }
val section = listOf(indent + "\t\"Customization\"", indent + "\t{", startupMovieSection(indent + "\t\t", path, newline), indent + "\t}").joinToString(newline)
return text.substring(0, lineStart) + section + newline + text.substring(lineStart)
}
private fun findSteamBlock(text: String): VdfBlock? {
val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
@@ -118,6 +118,20 @@ object SessionPrefs {
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
}
fun storesTabOrder(context: Context): List<String> = idList(prefs(context).getString("storesTabOrder", null))
fun setStoresTabOrder(context: Context, order: List<String>) {
prefs(context).edit().putString("storesTabOrder", order.joinToString(",")).apply()
}
fun storesTabHidden(context: Context): Set<String> = idList(prefs(context).getString("storesTabHidden", null)).toSet()
fun setStoresTabHidden(context: Context, hidden: Set<String>) {
prefs(context).edit().putString("storesTabHidden", hidden.joinToString(",")).apply()
}
private fun idList(raw: String?): List<String> = raw.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() }
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
val STORES_VIEWS = listOf("cards", "capsules", "list")
@@ -168,6 +182,7 @@ object SessionPrefs {
const val TOUCH_AUTO = "auto"
const val TOUCH_PAD = "touchpad"
const val TOUCH_DIRECT = "direct"
const val TOUCH_GESTURES = "gestures"
const val TOUCH_OFF = "off"
fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO
@@ -527,6 +542,17 @@ object SessionPrefs {
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true)
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100)
fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() }
/** How long the boot movie plays, in seconds; 0 plays all of it. */
fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0)
fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() }
/** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */
fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false)
fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() }
/** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */
fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null)
fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() }
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
fun setBootMovieCustomName(context: Context, name: String?) {
prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply()
@@ -878,6 +904,11 @@ object SessionPrefs {
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
fun displayMode(context: Context, mode: String, program: String?): String =
if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) &&
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
else prefMode(mode)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
fun setPipAutoEnter(context: Context, enabled: Boolean) {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
@@ -80,6 +81,7 @@ class SessionService : Service() {
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepIdle = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
@@ -293,14 +295,13 @@ class SessionService : Service() {
return lines
}
private fun tuDebug(linuxDriverId: String): String? {
val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
if (!override.isNullOrEmpty()) return override
if (SessionPrefs.tuSysmem(this)) return "sysmem"
if (linuxDriverId.isEmpty()) return null
private fun tuDebugOverride(): String? = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }?.takeIf { it.isNotEmpty() }
private fun sysmemDriver(linuxDriverId: String): Boolean {
if (linuxDriverId.isEmpty()) return false
val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase()
return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null
return name.contains("710-720") || name.contains("710_720")
}
@@ -319,7 +320,9 @@ class SessionService : Service() {
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO))
if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable")
SessionFiles.stage(this, root, startsSession = true)
// Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
SessionArtifacts.awaitCollections()
SessionArtifacts.finishAbandoned(this)
@@ -575,7 +578,9 @@ class SessionService : Service() {
)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
val tuOverride = tuDebugOverride()
val tuDriver = sysmemDriver(linuxDriverId)
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
@@ -603,8 +608,9 @@ class SessionService : Service() {
if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) &&
com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
extraEnv().forEach { guest.add(it) }
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
extra.forEach { guest.add(it) }
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
@@ -1019,7 +1025,7 @@ class SessionService : Service() {
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = false
@@ -1219,16 +1225,22 @@ class SessionService : Service() {
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
if (!steamPause) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
else -> false
}
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
@@ -1296,7 +1308,7 @@ class SessionService : Service() {
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
@@ -1307,13 +1319,14 @@ class SessionService : Service() {
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle))
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
@@ -1339,6 +1352,7 @@ class SessionService : Service() {
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = false
}
private fun requestSteamWake() {
@@ -1665,6 +1679,7 @@ class SessionService : Service() {
private const val TAG = "SessionService"
private const val TRACER_NICE = -6
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
private const val ENV_SWITCH_NAME = "droiddeck-env"
@@ -21,7 +21,8 @@ object SettingsReset {
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
"bootMovieApplyNext", "bootMovieSteamSeen",
"questDefaultsV1",
)
@@ -0,0 +1,218 @@
package com.droiddeck.launcher.session
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */
object SteamDeckRepo {
private const val TAG = "SteamDeckRepo"
const val BASE = "https://steamdeckrepo.com"
/** The site shows twelve movies a page. */
const val PAGE_SIZE = 12
const val MAX_DOWNLOAD_BYTES = 120L shl 20
private const val MAX_JSON_BYTES = 1 shl 20
private const val USER_AGENT = "DroidDeck (+https://github.com/Droid-Deck/DroidDeck)"
private const val CACHE_MS = 10 * 60 * 1000L
enum class Sort(val key: String) {
TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"),
}
/** `null` is every device. */
val DEVICES = listOf("steam_deck", "steam_machine")
class Post(
val id: String, val title: String, val content: String, val author: String, val thumbnail: String, val video: String,
val seconds: Int, val likes: Int, val downloads: Int, val devices: List<String>, val url: String,
)
sealed class Page {
class Ok(val posts: List<Post>) : Page()
object Failed : Page()
}
/** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */
private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
fun parse(json: String): List<Post>? {
val array = runCatching { JSONObject(json).optJSONArray("posts") }.getOrNull() ?: return null
val out = ArrayList<Post>()
for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue
val id = o.text("id")
val video = o.text("video")
if (id.isEmpty() || !video.startsWith("https://")) continue
val devices = o.optJSONArray("devices")?.let { d -> (0 until d.length()).map { d.optString(it) } }.orEmpty()
out.add(
Post(
id = id, title = o.text("title").ifBlank { id }, content = o.text("content"),
author = o.optJSONObject("user")?.text("steam_name").orEmpty(),
thumbnail = o.text("thumbnail").takeIf { it.startsWith("https://") }.orEmpty(), video = video,
seconds = o.optInt("video_duration"), likes = o.optInt("likes"), downloads = o.optInt("downloads"),
devices = devices, url = o.text("url"),
),
)
}
return out
}
fun url(sort: Sort, device: String?, search: String, page: Int = 1): String {
val query = StringBuilder("sort=").append(sort.key)
if (device != null) query.append("&device=").append(URLEncoder.encode(device, "UTF-8"))
val term = search.trim()
if (term.isNotEmpty()) query.append("&search=").append(URLEncoder.encode(term, "UTF-8").replace("+", "%20"))
if (page > 1) query.append("&page=").append(page)
return "$BASE/api/posts?$query"
}
private class Cached(val at: Long, val page: Page.Ok)
private val cache = HashMap<String, Cached>()
fun fetch(sort: Sort, device: String?, search: String, page: Int = 1, now: Long = System.currentTimeMillis()): Page {
val url = url(sort, device, search, page)
synchronized(cache) { cache[url]?.takeIf { now - it.at < CACHE_MS }?.let { return it.page } }
val body = get(url) ?: return Page.Failed
val posts = parse(body) ?: return Page.Failed
val result = Page.Ok(posts.take(PAGE_SIZE))
synchronized(cache) { cache[url] = Cached(now, result) }
return result
}
/**
* The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a
* page past the end is empty and the last one is short. Null when the site cannot be reached.
*/
fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p ->
(fetch(sort, device, search, p) as? Page.Ok)?.posts?.size
}): Int? {
val first = count(1) ?: return null
if (first < PAGE_SIZE) return 1
var good = 1
var bad = -1
var probe = 2
while (probe <= cap) {
val n = count(probe) ?: return null
if (n == 0) { bad = probe; break }
if (n < PAGE_SIZE) return probe
good = probe
probe *= 2
}
if (bad < 0) return good
while (bad - good > 1) {
val mid = (good + bad) / 2
val n = count(mid) ?: return null
if (n == 0) bad = mid else if (n < PAGE_SIZE) return mid else good = mid
}
return good
}
/** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */
internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? {
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(16 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
if (out.size() + n > limit) return null
out.write(buffer, 0, n)
}
return out.toByteArray()
}
private fun get(url: String): String? {
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json")
}
if (connection.responseCode !in 200..299) null else connection.inputStream.use { readLimited(it, MAX_JSON_BYTES)?.toString(Charsets.UTF_8) }
} catch (e: Exception) {
Log.w(TAG, "request failed: ${e.javaClass.simpleName}")
null
} finally {
runCatching { connection?.disconnect() }
}
}
/** Fetches a small picture such as a cover; null when it cannot be had. */
fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? {
if (!url.startsWith("https://")) return null
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299 || connection.contentLengthLong > limit) null
else connection.inputStream.use { input -> readLimited(input, limit)?.takeIf { it.isNotEmpty() } }
} catch (e: Exception) {
null
} finally {
runCatching { connection?.disconnect() }
}
}
/**
* Downloads a movie to [target] through the site's own download address, so the site counts it like a download from
* its website, and falls back to the video's address. True when a file was saved.
*/
fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean {
for (url in listOf("$BASE/post/download/${post.id}", post.video)) {
if (save(url, target, onProgress)) return true
}
return false
}
private fun save(url: String, target: File, onProgress: (Int) -> Unit): Boolean {
var connection: HttpURLConnection? = null
val staged = File(target.parentFile, target.name + ".part")
return try {
target.parentFile?.mkdirs()
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299) return false
val type = connection.contentType.orEmpty().lowercase()
if (type.startsWith("text/") || type.contains("json")) return false
val total = connection.contentLengthLong
if (total > MAX_DOWNLOAD_BYTES) return false
var done = 0L
var lastPercent = -1
connection.inputStream.use { input ->
staged.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
done += n
if (done > MAX_DOWNLOAD_BYTES) return false
output.write(buffer, 0, n)
if (total > 0) {
val percent = (done * 100 / total).toInt()
if (percent != lastPercent) { lastPercent = percent; onProgress(percent) }
}
}
}
}
done > 0 && (total <= 0 || done == total) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "download failed: ${e.javaClass.simpleName}")
false
} finally {
runCatching { connection?.disconnect() }
staged.delete()
}
}
}
@@ -37,7 +37,7 @@ object StoreLaunchRequests {
}
fun stop() {
Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start()
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
synchronized(lock) {
observer?.stopWatching()
observer = null
@@ -52,9 +52,10 @@ object StoreLaunchRequests {
}
val reply = when (request.optString("op")) {
"xbox-released" -> {
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id"))
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease"))
JSONObject().put("ok", true)
}
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
@@ -0,0 +1,9 @@
package com.droiddeck.launcher.stores
data class StoreTab(val store: Store, val shown: Boolean)
fun arrangeStoreTabs(order: List<String>, hidden: Set<String>): List<StoreTab> {
val known = Store.entries.associateBy { it.id }
val listed = order.mapNotNull { known[it] }.distinct()
return (listed + Store.entries.filterNot { it in listed }).map { StoreTab(it, it.id !in hidden) }
}
@@ -168,10 +168,12 @@ object StoresState {
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
background("stores-signout") {
backends[store]?.signOut(app)
val backend = backends[store]
backend?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else StoreAccounts.clear(app, store)
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
main.post {
if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
}
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionPrefs
@@ -24,7 +25,8 @@ import java.util.concurrent.TimeUnit
object GogComet {
private const val TAG = "GogComet"
private const val BIN = "/usr/local/lib/droiddeck-comet/comet"
private const val DIR = "/usr/local/lib/droiddeck-comet"
private const val BIN = "$DIR/comet"
private val lock = Any()
@Volatile private var process: Process? = null
private var pid = -1
@@ -41,7 +43,27 @@ object GogComet {
fun setEnabled(context: Context, on: Boolean) {
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
if (on) AddonPack.COMET.ensure(context)
}
private fun stage(context: Context): Boolean {
if (!AddonPack.COMET.installed(context)) {
AddonPack.COMET.ensure(context)
return false
}
val pack = AddonPack.COMET.dir(context)
val root = File(LinuxRuntime.rootDir(context), DIR.removePrefix("/"))
val packaged = File(pack, "version").readText()
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return true
root.mkdirs()
pack.listFiles().orEmpty().filter { it.isFile && it.name != "version" && it.name != AddonPack.COMPLETE }.forEach { source ->
val staged = File(root, source.name + ".staged")
source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } }
staged.setExecutable(true, false)
check(staged.renameTo(File(root, source.name)))
}
File(root, "version").writeText(packaged)
return true
}
fun begin() = synchronized(lock) {
@@ -113,7 +135,8 @@ object GogComet {
return JSONObject().put("ok", true).put("ready", true).put("reason", "ready")
}
stopLocked()
if (!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
if (!runCatching { stage(context) }.onFailure { Log.w(TAG, "staging failed", it) }.getOrDefault(false) ||
!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
val data = File(context.filesDir, "gog-achievements").apply { mkdirs() }
val ready = CountDownLatch(1)
val command = LinuxRuntime.command(context, null, null, Environment.getExternalStorageDirectory(),
@@ -19,12 +19,15 @@ object XboxBackend : StoreBackend {
context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@Synchronized override fun signOut(context: Context) {
generation++
XboxCredentials.invalidate(context)
XboxLibraryCache.clear(context)
XboxRuntime.stop()
access = emptyMap()
override fun signOut(context: Context) {
XboxCredentials.invalidate(context) {
synchronized(this) {
generation++
access = emptyMap()
}
XboxLibraryCache.clear(context)
XboxRuntime.stop()
}
StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) }
}
@@ -51,8 +54,12 @@ object XboxBackend : StoreBackend {
}
}
if (cached != null && !cached.needsRefresh(force)) return@Thread
val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market)
val games = XboxCatalog.products(licenses.keys.toList(), licenses, market).sortedBy { it.title.lowercase() }
val response = Xodus.run(app, listOf("library", "--json", "--market", market))
val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") })
val products = XboxCatalog.rawProducts(licenses.keys.toList(), market)
val games = products.mapNotNull { XboxCatalog.product(it, licenses) }.sortedBy { it.title.lowercase() }
StoresState.logLine("Xbox: library catalog requested=${licenses.size} returned=${products.size} supported=${games.size} " +
"filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")}")
synchronized(this) {
if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread
access = licenses
@@ -11,10 +11,25 @@ internal object XboxCatalog {
private val productId = Regex("[A-Z0-9]{12}")
private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now()): Map<String, String> {
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)
val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds
require(age in -60..299)
val memberships: Set<String>? = if (json.has("subscriptions")) {
val subscriptions = json.getJSONArray("subscriptions")
require(subscriptions.length() <= plans.size)
val seen = HashSet<String>()
buildSet {
for (i in 0 until subscriptions.length()) {
val row = subscriptions.getJSONObject(i)
val id = row.getString("productId")
require(id in plans && seen.add(id))
val status = row.getString("status")
val expiry = timestamp(row.getString("endDate"))
if (status == "Active" && expiry.isAfter(now)) add(id)
}
}
} else null
val array = json.getJSONArray("entitlements")
require(array.length() <= 10_000)
val rows = LinkedHashMap<String, JSONObject>()
@@ -28,18 +43,24 @@ internal object XboxCatalog {
(row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId")))
if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row
}
fun active(row: JSONObject) = row.getString("status") == "Active" && !row.getBoolean("isTrial") && !row.getBoolean("special") &&
row.getString("sharingSource") == "None" && timestamp(row.getString("endDate")).isAfter(now)
fun activePlan(id: String) = rows[id]?.let(::active) ?: (id in memberships.orEmpty())
fun kind(id: String, visited: Set<String> = emptySet()): String? {
if (id in blocked || id in visited || visited.size >= 16) return null
val row = rows[id] ?: return null
if (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") ||
row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null
if (!active(row)) return null
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
if (parent == null) return if (id in plans) null else "owned"
if (parent in blocked) return null
if (parent in plans) return "gamepass"
if (parent in plans) return if (activePlan(parent)) "gamepass" else null
return kind(parent, visited + id)
}
return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
val missingPlan = rows.values.count { row -> row.optString("satisfiedByProductId").let { it in plans && !activePlan(it) } }
report("library entitlements=${array.length()} invalid=${array.length() - rows.size} restricted=${rows.values.count { !active(it) }} " +
"unverified-plan=$missingPlan owned=${result.values.count { it == "owned" }} gamepass=${result.values.count { it == "gamepass" }}")
return result
}
private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant()
@@ -10,11 +10,68 @@ import java.io.Closeable
import java.io.InputStream
import java.util.UUID
internal class XboxCredentials(private val app: Context) : Closeable {
internal class XboxCredentials(private val app: Context, expectedGeneration: String? = null) : Closeable {
val name = "droiddeck-xbox-${UUID.randomUUID()}"
private val generation = storage(app) { epoch(app) }
private val generation = lifecycle(app, expectedGeneration) { it }
private val server = LocalServerSocket(name)
@Volatile private var closed = false
private val bound = java.util.concurrent.CountDownLatch(1)
private var backend: Process? = null
private var binaryHash = byteArrayOf()
private lateinit var binary: java.io.File
private val peers = java.util.concurrent.ConcurrentHashMap<Int, String>()
fun bind(process: Process, file: java.io.File, digest: ByteArray) {
backend = process
binary = file.canonicalFile
binaryHash = digest
bound.countDown()
}
private fun rejected(reason: String): Boolean {
android.util.Log.w("XboxCredentials", "Rejected credential peer: $reason")
return false
}
private fun authorized(pid: Int): Boolean {
if (!bound.await(5, java.util.concurrent.TimeUnit.SECONDS) || closed) return rejected("unbound")
val process = backend ?: return rejected("missing-child")
if (!process.isAlive) return rejected("exited-child")
val root = com.droiddeck.launcher.core.HostProcess.pidOf(process)
if (root <= 0 || pid <= 0) return rejected("missing-process-id")
val identity = identity(pid, root) ?: return rejected("foreign-backend")
if (peers[pid] == identity) return true
val digest = java.security.MessageDigest.getInstance("SHA-256")
binary.inputStream().use { input ->
val buffer = ByteArray(65536)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
if (!java.security.MessageDigest.isEqual(binaryHash, digest.digest())) return rejected("different-executable")
peers[pid] = identity
return true
}
private fun identity(pid: Int, root: Int): String? {
if (root <= 0 || pid <= 0) return null
val status = java.io.File("/proc/$pid/status").readLines()
val tracer = status.first { it.startsWith("TracerPid:") }.substringAfter(':').trim().toInt()
val tracerGroup = java.io.File("/proc/$tracer/status").readLines()
.first { it.startsWith("Tgid:") }.substringAfter(':').trim().toInt()
if (tracerGroup != root) return null
val executable = android.system.Os.stat(binary.path)
if (!mapsBackend(java.io.File("/proc/$pid/maps").readText(), binary.path, executable.st_ino))
return null
return java.io.File("/proc/$pid/stat").readText().substringAfterLast(") ").split(' ')[19] + ":${executable.st_dev}:${executable.st_ino}"
}
fun signalBackend(signal: Int) {
val process = backend ?: return
if (!process.isAlive) return
val root = com.droiddeck.launcher.core.HostProcess.pidOf(process)
peers.forEach { (pid, expected) -> runCatching {
if (identity(pid, root) == expected) android.system.Os.kill(pid, signal)
} }
}
init {
Thread({
@@ -23,12 +80,17 @@ internal class XboxCredentials(private val app: Context) : Closeable {
socket.use {
it.soTimeout = 10_000
val reply = runCatching {
check(it.peerCredentials.uid == android.os.Process.myUid())
val peer = it.peerCredentials
check(peer.uid == android.os.Process.myUid() && authorized(peer.pid))
val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152))
storage(app) {
check(epoch(app) == generation)
answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152)))
check(!closed && epoch(app) == generation && authorized(peer.pid))
answer(request)
}
}.getOrElse { JSONObject().put("ok", false) }
}.getOrElse {
android.util.Log.w("XboxCredentials", "Credential request rejected: ${it.javaClass.simpleName}")
JSONObject().put("ok", false)
}
runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) }
}
}
@@ -58,14 +120,25 @@ internal class XboxCredentials(private val app: Context) : Closeable {
return result
}
override fun close() { closed = true; runCatching { server.close() } }
override fun close() { closed = true; bound.countDown(); runCatching { server.close() } }
companion object {
val lock = Any()
internal fun mapsBackend(maps: String, path: String, inode: Long): Boolean = maps.lineSequence().any { line ->
val fields = line.trim().split(Regex("\\s+"), limit = 6)
fields.size == 6 && 'x' in fields[1] && fields[4].toLongOrNull() == inode &&
runCatching { java.io.File(fields[5]).canonicalPath == path }.getOrDefault(false)
}
private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("")
fun invalidate(app: Context) = storage(app) {
fun <T> lifecycle(app: Context, expected: String? = null, block: (String) -> T): T = storage(app) {
val current = epoch(app)
check(expected == null || expected == current) { "Xbox account changed during the request" }
block(current)
}
fun invalidate(app: Context, revoked: () -> Unit = {}) = storage(app) {
java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString())
StoreAccounts.clear(app, Store.XBOX)
revoked()
}
fun <T> storage(app: Context, block: () -> T): T = synchronized(lock) {
java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file ->
@@ -0,0 +1,38 @@
package com.droiddeck.launcher.stores.xbox
import java.io.Closeable
import java.util.UUID
internal class XboxLaunchLeases(private val now: () -> Long) {
private data class Lease(val id: String, val account: String, val session: String, var expires: Long, var child: Closeable? = null)
private val leases = HashMap<String, Lease>()
@Synchronized fun active(id: String) = leases.values.any { it.id == id }
@Synchronized fun begin(id: String, account: String, session: String): String {
check(!active(id)) { "This Xbox game is already starting or running." }
val token = UUID.randomUUID().toString()
leases[token] = Lease(id, account, session, now() + 180_000)
return token
}
@Synchronized fun attach(token: String, child: Closeable): Boolean {
val lease = leases[token] ?: return false
lease.child = child
return true
}
@Synchronized fun renew(token: String, id: String, account: String?, session: String?): Boolean {
val lease = leases[token] ?: return false
if (lease.id != id || lease.account != account || lease.session != session || lease.expires <= now()) return false
lease.expires = now() + 60_000
return true
}
@Synchronized fun release(token: String): Closeable? = leases.remove(token)?.child
@Synchronized fun stale(account: String?, session: String?, suspended: Boolean): List<Closeable> {
if (suspended) leases.values.filter { it.account == account && it.session == session }
.forEach { it.expires = maxOf(it.expires, now() + 60_000) }
val expired = leases.filterValues { it.account != account || it.session != session || (!suspended && it.expires <= now()) }.keys
return expired.mapNotNull { leases.remove(it)?.child }
}
@Synchronized fun clear(): List<Closeable> {
return leases.values.mapNotNull { it.child }.also { leases.clear() }
}
}
@@ -0,0 +1,40 @@
package com.droiddeck.launcher.stores.xbox
import java.io.File
import java.nio.file.Files
import java.nio.file.FileVisitResult
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
internal class XboxLaunchViews {
private val recovered = HashSet<String>()
@Synchronized fun create(files: File): File {
val parent = File(files, "xbox-runtime/views").canonicalFile
check(parent == File(files.canonicalFile, "xbox-runtime/views")) { "Xbox launch storage contains a redirected path" }
check(parent.mkdirs() || parent.isDirectory)
if (parent.path !in recovered) {
parent.listFiles()?.filter { it.name.startsWith("launch-") }?.forEach(::remove)
recovered.add(parent.path)
}
return Files.createTempDirectory(parent.toPath(), "launch-").toFile()
}
companion object {
fun remove(directory: File) {
if (!Files.exists(directory.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walkFileTree(directory.toPath(), object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.deleteIfExists(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult {
if (error != null) throw error
Files.deleteIfExists(dir)
return FileVisitResult.CONTINUE
}
})
}
}
}
@@ -48,7 +48,8 @@ class XboxLoginActivity : ComponentActivity() {
Thread({
if (closed) return@Thread
try {
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process ->
val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it }
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process ->
child = process
if (closed) return@use
Xodus.diagnostics(process)
@@ -99,9 +100,9 @@ class XboxLoginActivity : ComponentActivity() {
}
child = null
if (closed) return@Thread
val account = Xodus.run(applicationContext, listOf("account", "--json"))
val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration)
check(account.optBoolean("stored"))
XboxCredentials.storage(this) {
XboxCredentials.lifecycle(this, accountGeneration) {
val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing")
record.put("username", account.getString("username"))
StoreAccounts.write(this, Store.XBOX, record)
@@ -7,40 +7,110 @@ import org.json.JSONObject
import java.io.File
internal object XboxRuntime {
private val state = Any()
private val startup = Any()
private var child: Xodus.Child? = null
private var owner: String? = null
private val activeGames = HashMap<String, Int>()
@Synchronized fun isActive(id: String): Boolean = activeGames.getOrDefault(id, 0) > 0
@Synchronized fun released(id: String) {
val count = activeGames.getOrDefault(id, 0)
if (count <= 1) activeGames.remove(id) else activeGames[id] = count - 1
}
private var serviceSocket: File? = null
private var generation = 0L
private val views = XboxLaunchViews()
private val launches = XboxLaunchLeases { android.os.SystemClock.uptimeMillis() }
fun isActive(id: String): Boolean = launches.active(id)
fun released(token: String) { runCatching { launches.release(token)?.close() } }
fun renew(app: Context, id: String, token: String): Boolean = launches.renew(token, id,
StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running })
@Synchronized fun prepare(app: Context, id: String): JSONObject {
private data class Authorization(val account: String, val token: String, val runtime: Long, val credentials: String)
fun prepare(app: Context, id: String): JSONObject {
check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" }
check(SessionState.running) { "No running DroidDeck session" }
check(StoreGameSidecar.find(app, Store.XBOX, id)?.isInstalled == true) { "Xbox game is not installed" }
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
val socket = File(app.filesDir, "xbox-runtime/xodus.sock")
if (child?.process?.isAlive != true || owner != account) {
stop()
socket.delete()
val service = Xodus.start(app, emptyList(), service = true)
child = service; owner = account
Xodus.diagnostics(service)
Thread({ runCatching { service.process.inputStream.use { val buffer = ByteArray(8192); while (it.read(buffer) >= 0) {} } } }, "xbox-service-output").start()
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
while (!socket.exists() && service.process.isAlive && SessionState.running && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
if (!socket.exists() || !service.process.isAlive || !SessionState.running) {
stop(); error("Xbox account service did not become ready. Check your sign-in and network connection.")
}
val session = SessionState.sessionId ?: error("No running DroidDeck session")
val game = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.store == Store.XBOX && it.id == id && it.isInstalled }?.let { folder to it }
}.firstOrNull() ?: error("Xbox game is not installed")
val (account, token, current, credentials) = XboxCredentials.lifecycle(app) { epoch ->
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
synchronized(state) { Authorization(account, launches.begin(id, account, session), generation, epoch) }
}
activeGames[id] = activeGames.getOrDefault(id, 0) + 1
return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket)
Thread({
while (isActive(id)) {
Thread.sleep(10_000)
launches.stale(StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running },
SessionState.suspended).forEach { runCatching { it.close() } }
}
}, "xbox-launch-expiry").start()
try {
val socket = service(app, account, current, credentials)
check(synchronized(state) { generation == current }) { "Xbox launch was cancelled" }
val market = game.second.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market()
val directory = views.create(app.filesDir)
val process = try {
Xodus.start(app, listOf("run", game.first.path, "droiddeck:prepare", "--exe", game.second.exe,
"--materialize-chain", "--market", market), launchDirectory = directory, credentialGeneration = credentials)
} catch (e: Exception) { XboxLaunchViews.remove(directory); throw e }
if (!launches.attach(token, process)) { process.close(); error("Xbox launch was cancelled") }
Xodus.diagnostics(process)
val result = java.util.concurrent.FutureTask {
val input = process.process.inputStream.buffered()
var line: String
do { line = XboxCredentials.readLine(input, 131_072) } while (!line.startsWith("DROIDDECK_LAUNCH_V1 "))
JSONObject(line.removePrefix("DROIDDECK_LAUNCH_V1 "))
}
Thread(result, "xbox-launch-view").start()
val view = result.get(90, java.util.concurrent.TimeUnit.SECONDS)
check(renew(app, id, token) && synchronized(state) { generation == current }) { "Xbox launch was cancelled" }
return JSONObject().put("ok", true).put("socket", socket.path).put("lease", token)
.put("exe", view.getString("exe")).put("playfab", view.opt("playfab"))
} catch (e: Exception) { released(token); throw e }
}
@Synchronized fun stop() {
val process = child; child = null; owner = null; activeGames.clear()
runCatching { process?.close() }
internal fun newServiceSocket(files: File): File =
File(files, "xbox-runtime/s-${java.util.UUID.randomUUID().toString().replace("-", "")}/xodus.sock")
private fun service(app: Context, account: String, current: Long, credentials: String): File = synchronized(startup) {
val existing = synchronized(state) {
check(generation == current) { "Xbox launch was cancelled" }
serviceSocket?.takeIf { child?.process?.isAlive == true && owner == account }
}
val socket = existing ?: newServiceSocket(app.filesDir)
if (existing == null) {
val old = synchronized(state) { child.also { child = null; owner = null; serviceSocket = null } }
old?.close()
socket.delete()
val service = Xodus.start(app, emptyList(), service = true, runtimeDirectory = socket.parentFile!!, credentialGeneration = credentials)
val accepted = synchronized(state) {
(generation == current).also { if (it) { child = service; owner = account; serviceSocket = socket } }
}
if (!accepted) { service.close(); error("Xbox launch was cancelled") }
try {
Xodus.diagnostics(service)
Thread({ runCatching { service.process.inputStream.use { input ->
val buffer = ByteArray(8192)
while (input.read(buffer) >= 0) { Thread.yield() }
} } }, "xbox-service-output").start()
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
while (!socket.exists() && service.process.isAlive && SessionState.running &&
synchronized(state) { generation == current } && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
check(socket.exists() && service.process.isAlive && SessionState.running) {
"Xbox account service did not become ready. Check your sign-in and network connection."
}
} catch (e: Exception) {
synchronized(state) { if (child === service) { child = null; owner = null; serviceSocket = null } }
service.close()
throw e
}
}
socket
}
fun stop() {
val processes = synchronized(state) {
generation++
val all = launches.clear() + listOfNotNull(child)
child = null; owner = null; serviceSocket = null
all
}
Thread({ processes.forEach { runCatching { it.close() } } }, "xbox-runtime-stop").start()
}
}
@@ -2,6 +2,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.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
@@ -13,51 +14,70 @@ internal object Xodus {
const val DIR = "/usr/local/lib/droiddeck-xbox"
fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
class Child(val process: Process, private val credentials: XboxCredentials) : Closeable {
val credentialSocket: String get() = credentials.name
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable {
@Synchronized fun send(frame: JSONObject) {
process.outputStream.write((frame.toString() + "\n").toByteArray())
process.outputStream.flush()
}
override fun close() {
process.destroy()
if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
credentials.close()
try {
credentials.close()
runCatching { process.outputStream.close() }
if (!process.waitFor(2, TimeUnit.SECONDS)) {
credentials.signalBackend(android.system.OsConstants.SIGINT)
if (!process.waitFor(2, TimeUnit.SECONDS)) {
credentials.signalBackend(android.system.OsConstants.SIGKILL)
if (!process.waitFor(1, TimeUnit.SECONDS)) {
val pid = com.droiddeck.launcher.core.HostProcess.pidOf(process)
if (pid > 0) android.os.Process.killProcess(pid)
}
}
}
} finally {
runtimeDirectory?.let { directory ->
File(directory, "xodus.sock").delete()
directory.delete()
}
launchDirectory?.let(XboxLaunchViews::remove)
}
}
}
@Synchronized private fun stage(app: Context) {
@Synchronized private fun stage(app: Context): File {
check(AddonPack.XBOX.await(app)) { "Couldn't download the Xbox components. Check your connection and try again." }
val pack = AddonPack.XBOX.dir(app)
val root = File(LinuxRuntime.rootDir(app), DIR.removePrefix("/"))
val packaged = app.assets.open("linuxfs$DIR/version").bufferedReader().use { it.readText() }
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return
fun copy(relative: String) {
val entries = app.assets.list("linuxfs$DIR/$relative").orEmpty()
if (entries.isNotEmpty()) { entries.forEach { copy(if (relative.isEmpty()) it else "$relative/$it") }; return }
if (relative == "version") return
val packaged = File(pack, "version").readText()
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return pack
fun copy(source: File, relative: String) {
if (source.isDirectory) { source.listFiles().orEmpty().forEach { copy(it, if (relative.isEmpty()) it.name else "$relative/${it.name}") }; return }
if (relative == "version" || relative == AddonPack.COMPLETE) return
val file = File(root, relative)
file.parentFile?.mkdirs()
val staged = File(file.path + ".staged")
app.assets.open("linuxfs$DIR/$relative").use { input -> staged.outputStream().use { input.copyTo(it) } }
source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } }
staged.setExecutable(true, false)
check(staged.renameTo(file))
}
copy("")
copy(pack, "")
File(root, "version").writeText(packaged)
return pack
}
fun start(app: Context, args: List<String>, service: Boolean = false): Child {
fun start(app: Context, args: List<String>, service: Boolean = false, runtimeDirectory: File? = null, launchDirectory: File? = null, credentialGeneration: String? = null): Child {
check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." }
stage(app)
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 credentials = XboxCredentials(app)
val credentials = XboxCredentials(app, credentialGeneration)
try {
val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() }
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",
"XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary,
) + args)
val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app))
if (launchDirectory != null) builder.environment()["TMPDIR"] = launchDirectory.path
builder.environment().apply {
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
@@ -65,12 +85,19 @@ internal object Xodus {
if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
return Child(builder.start(), credentials)
val digest = java.security.MessageDigest.getInstance("SHA-256")
File(pack, binary.removePrefix("$DIR/")).inputStream().use { input ->
val buffer = ByteArray(65536)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
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 }
}
fun run(app: Context, args: List<String>, timeout: Long = 90): JSONObject {
start(app, args).use { child ->
fun run(app: Context, args: List<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
start(app, args, credentialGeneration = credentialGeneration).use { child ->
val output = java.io.ByteArrayOutputStream()
var readError: Throwable? = null
val reader = Thread({
@@ -305,6 +305,7 @@ internal fun AppDialog(
modifier: Modifier = Modifier, maxWidth: Dp? = null,
width: Dp? = null, fitContent: Boolean = false,
back: (() -> Unit)? = null,
scrollable: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
val pal = LocalPalette.current
@@ -342,7 +343,7 @@ internal fun AppDialog(
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.controllerBack(back ?: close)
.then(modifier)
.verticalScroll(rememberScrollState())
.then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier)
.padding(horizontal = 18.dp, vertical = 16.dp),
content = content,
)
@@ -0,0 +1,695 @@
package com.droiddeck.launcher.ui
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.graphics.SurfaceTexture
import android.media.MediaPlayer
import android.net.Uri
import android.view.Surface
import android.view.TextureView
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
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.input.InputMode
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.viewinterop.AndroidView
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.BootMovie
import com.droiddeck.launcher.session.BootMovieLibrary
import com.droiddeck.launcher.session.BootMovieReport
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamDeckRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private enum class MovieTab { INSTALLED, IMPORT, REPO, SOUND }
/** Downloads run here rather than in a tab, so switching tabs or closing the settings does not cancel one halfway. */
private val installScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
/** The downloads under way, by post id and percent; kept here so they are still known when the tab or the settings come back. */
private val installProgress = mutableStateMapOf<String, Int>()
/** What the movie list looks like right now: every movie and the one Steam will play. */
private class MovieSnapshot(val entries: List<BootMovieLibrary.Entry>, val selected: String)
/** The movie list, read again every couple of seconds so a movie picked, added or removed inside Steam shows up here too. */
@Composable
private fun rememberMovies(refresh: Int, onSelectionChanged: () -> Unit): MovieSnapshot? {
val app = LocalContext.current.applicationContext
var snapshot by remember { mutableStateOf<MovieSnapshot?>(null) }
val changed by androidx.compose.runtime.rememberUpdatedState(onSelectionChanged)
LaunchedEffect(refresh) {
while (true) {
val before = snapshot?.selected
snapshot = withContext(Dispatchers.IO) {
BootMovie.syncFromSteam(app)
MovieSnapshot(BootMovieLibrary.list(app), BootMovie.choice(app))
}
if (before != null && before != snapshot?.selected) changed()
delay(2_000)
}
}
return snapshot
}
@Composable
private fun TabChip(label: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(8.dp)
Box(
contentAlignment = Alignment.Center,
modifier = modifier.clip(shape)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal)
.hoverable(src)
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = label; this.selected = selected }
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(label, fontSize = 12.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/** The "Boot movie" settings group: Installed movies, Import your own movies, SteamDeckRepo and Sound. */
@Composable
internal fun BootMovieGroup(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) {
var tab by rememberSaveable { mutableStateOf(MovieTab.INSTALLED) }
SettingsGroup(stringResource(R.string.boot_movie)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)) {
TabChip(stringResource(R.string.boot_movie_tab_installed), tab == MovieTab.INSTALLED, Modifier.weight(1f)) { tab = MovieTab.INSTALLED }
TabChip(stringResource(R.string.boot_movie_tab_import), tab == MovieTab.IMPORT, Modifier.weight(1f)) { tab = MovieTab.IMPORT }
TabChip(stringResource(R.string.boot_movie_tab_repo), tab == MovieTab.REPO, Modifier.weight(1f)) { tab = MovieTab.REPO }
TabChip(stringResource(R.string.boot_movie_tab_sound), tab == MovieTab.SOUND, Modifier.weight(1f)) { tab = MovieTab.SOUND }
}
when (tab) {
MovieTab.INSTALLED -> InstalledTab(a)
MovieTab.IMPORT -> ImportTab(s, a)
MovieTab.REPO -> RepoTab(a)
MovieTab.SOUND -> SoundTab(s, a, host)
}
}
}
@Composable
private fun InstalledTab(a: ModeSettingsActions) {
var browsing by remember { mutableStateOf(false) }
val snapshot = rememberMovies(0, a.onBootMovieChanged)
val current = snapshot?.let { snap -> snap.entries.firstOrNull { it.id == snap.selected }?.title }
val hint = current ?: stringResource(R.string.common_working)
SettingsRow(stringResource(R.string.boot_movie_installed), hint) {
SecondaryButton(stringResource(R.string.boot_movie_browse), onClick = { browsing = true })
}
Text(
stringResource(R.string.boot_movie_note), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp),
)
val app = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
var saving by remember { mutableStateOf(false) }
// The report is for bug reports, so it shows only while Debugging tools is on (read each time this tab opens).
val debugging = remember { com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(app) }
if (debugging) {
SettingsRow(stringResource(R.string.boot_movie_report), stringResource(R.string.boot_movie_report_hint)) {
SecondaryButton(stringResource(R.string.boot_movie_report_save), enabled = !saving, onClick = {
saving = true
scope.launch {
val file = withContext(Dispatchers.IO) { BootMovieReport.write(app) }
saving = false
Toast.makeText(
app,
file?.let { app.getString(R.string.boot_movie_report_saved, it.path) } ?: app.getString(R.string.boot_movie_report_failed),
Toast.LENGTH_LONG,
).show()
}
})
}
}
if (browsing) InstalledMoviesDialog(a) { browsing = false }
}
@Composable
private fun MovieCover(entry: BootMovieLibrary.Entry, modifier: Modifier = Modifier) {
val app = LocalContext.current.applicationContext
val cover by androidx.compose.runtime.produceState<File?>(null, entry.id, entry.title) {
value = withContext(Dispatchers.IO) { BootMovieLibrary.cover(app, entry) }
}
Box(modifier.aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0E1218))) {
cover?.let { AsyncImage(model = it, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
}
}
@Composable
private fun movieSourceLabel(source: String): String = stringResource(
when (source) {
BootMovieLibrary.SOURCE_DROIDDECK -> R.string.boot_movie_source_droiddeck
BootMovieLibrary.SOURCE_REPO -> R.string.boot_movie_source_repo
BootMovieLibrary.SOURCE_IMPORT -> R.string.boot_movie_source_import
BootMovieLibrary.SOURCE_POINTSHOP -> R.string.boot_movie_source_pointshop
else -> R.string.boot_movie_source_steam
},
)
/** The installed movies as a list with covers; the one Steam plays is highlighted, and the list follows changes made in Steam. */
@Composable
private fun InstalledMoviesDialog(a: ModeSettingsActions, onDismiss: () -> Unit) {
val app = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var refresh by remember { mutableIntStateOf(0) }
val snapshot = rememberMovies(refresh, a.onBootMovieChanged)
var picked by remember { mutableStateOf<String?>(null) }
var removing by remember { mutableStateOf<BootMovieLibrary.Entry?>(null) }
var filter by rememberSaveable { mutableStateOf<String?>(null) }
val selected = picked ?: snapshot?.selected
LaunchedEffect(snapshot?.selected) { picked = null }
removing?.let { entry ->
RemoveMovieDialog(
entry,
onConfirm = {
removing = null
scope.launch {
withContext(Dispatchers.IO) { BootMovieLibrary.remove(app, entry.id) }
a.onBootMovieChanged()
refresh++
}
},
onDismiss = { removing = null },
)
}
val firstRow = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
LaunchedEffect(snapshot != null, filter) {
if (snapshot != null && inputMode.inputMode == InputMode.Keyboard) focusSoon { firstRow }
}
AppDialog(shown, close, "boot-movies", wide = false, scrollable = false) {
val filters = listOf(
null to stringResource(R.string.store_all),
BootMovieLibrary.SOURCE_DROIDDECK to movieSourceLabel(BootMovieLibrary.SOURCE_DROIDDECK),
BootMovieLibrary.SOURCE_STEAM to movieSourceLabel(BootMovieLibrary.SOURCE_STEAM),
BootMovieLibrary.SOURCE_POINTSHOP to movieSourceLabel(BootMovieLibrary.SOURCE_POINTSHOP),
BootMovieLibrary.SOURCE_REPO to movieSourceLabel(BootMovieLibrary.SOURCE_REPO),
BootMovieLibrary.SOURCE_IMPORT to movieSourceLabel(BootMovieLibrary.SOURCE_IMPORT),
)
var filterOpen by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
Text(
stringResource(R.string.boot_movie_installed), fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
)
Box {
androidx.compose.runtime.CompositionLocalProvider(LocalChipMinWidth provides 100.dp) {
ValueChip(filters.first { it.first == filter }.second, filterOpen) { filterOpen = !filterOpen }
}
AnchoredMenu(filterOpen, onDismiss = { filterOpen = false }, compact = true) { firstItemFocus ->
filters.forEachIndexed { index, (source, label) ->
MenuItem(label, checked = filter == source, focusRequester = if (index == 0) firstItemFocus else null) {
filter = source
filterOpen = false
}
}
}
}
SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = MaterialTheme.colorScheme.error, onClick = close)
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().weight(1f, fill = false)) {
itemsIndexed(snapshot?.entries.orEmpty().filter { filter == null || it.source == filter }, key = { _, entry -> entry.id }) { index, entry ->
ListRow(accent = entry.id == selected, tall = true, focus = if (index == 0) firstRow else null, onClick = { picked = entry.id; a.onBootMovie(entry.id) }) {
MovieCover(entry, Modifier.size(width = 96.dp, height = 54.dp))
Column(Modifier.weight(1f)) {
Text(entry.title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground,
maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(movieSourceLabel(entry.source), fontSize = 12.sp, color = if (entry.id == selected) LocalPalette.current.signal else MaterialTheme.colorScheme.onSurfaceVariant)
}
if (entry.removable) SmallIconButton(Icons.Filled.Close, stringResource(R.string.boot_movie_remove), MaterialTheme.colorScheme.error) {
removing = entry
}
}
}
}
}
}
@Composable
private fun RemoveMovieDialog(entry: BootMovieLibrary.Entry, onConfirm: () -> Unit, onDismiss: () -> Unit) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.boot_movie_remove_confirm, entry.title)) },
text = { Text(stringResource(R.string.boot_movie_remove_confirm_body)) },
confirmButton = { androidx.compose.material3.TextButton(onClick = onConfirm) { Text(stringResource(R.string.boot_movie_remove)) } },
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
)
}
@Composable
private fun ImportTab(s: ModeSettings, a: ModeSettingsActions) {
SettingsRow(
stringResource(R.string.boot_movie_custom),
stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS),
) {
SecondaryButton(
when {
s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress)
s.bootMovieImporting -> stringResource(R.string.common_working)
else -> stringResource(R.string.boot_movie_import)
},
enabled = !s.bootMovieImporting,
progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f },
onClick = a.onImportBootMovie,
)
}
}
@Composable
private fun SoundTab(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) {
val app = LocalContext.current.applicationContext
var volume by remember { mutableIntStateOf(SessionPrefs.bootMovieVolume(app)) }
var seconds by remember { mutableIntStateOf(SessionPrefs.bootMovieSeconds(app)) }
val duration by androidx.compose.runtime.produceState(0, s.bootMovie) {
value = withContext(Dispatchers.IO) { BootMovieLibrary.durationSeconds(BootMovie.currentFile(app)) }
}
ToggleRow(
host, "boot-movie-sound", stringResource(R.string.boot_movie_sound),
stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound,
)
Box(Modifier.padding(horizontal = 6.dp)) {
ValueSlider(
volume, 0..100, 5, s.bootMovieSound, stringResource(R.string.boot_movie_volume), { "$it%" },
) { volume = it; SessionPrefs.setBootMovieVolume(app, it) }
}
Box(Modifier.padding(horizontal = 6.dp)) {
val shownSeconds = if (seconds in 1 until duration) seconds else duration
ValueSlider(
shownSeconds, 1..duration.coerceAtLeast(2), 1, duration > 1, stringResource(R.string.boot_movie_length),
{ value -> app.getString(R.string.boot_movie_seconds, value) },
) { next ->
val stored = if (next >= duration) 0 else next
seconds = stored
SessionPrefs.setBootMovieSeconds(app, stored)
}
}
Text(
stringResource(R.string.boot_movie_length_hint), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp),
)
}
@Composable
private fun RepoTab(a: ModeSettingsActions) {
val progress = installProgress
val app = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
var sort by rememberSaveable { mutableStateOf(SteamDeckRepo.Sort.TRENDING) }
var device by rememberSaveable { mutableStateOf<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var page by rememberSaveable { mutableIntStateOf(1) }
var retry by remember { mutableIntStateOf(0) }
var searching by remember { mutableStateOf(false) }
var jumping by remember { mutableStateOf(false) }
var previewing by remember { mutableStateOf<SteamDeckRepo.Post?>(null) }
var installed by remember { mutableStateOf(setOf<String>()) }
val chosen = rememberMovies(0, a.onBootMovieChanged)?.selected
val inUse by androidx.compose.runtime.produceState<String?>(null, chosen) {
value = withContext(Dispatchers.IO) {
chosen?.takeIf { it.startsWith(BootMovieLibrary.PREFIX) }?.let { BootMovieLibrary.repoId(app, it.removePrefix(BootMovieLibrary.PREFIX)) }
}
}
LaunchedEffect(progress.size) { installed = withContext(Dispatchers.IO) { BootMovieLibrary.installedRepoIds(app) } }
val result by androidx.compose.runtime.produceState<SteamDeckRepo.Page?>(null, sort, device, query, page, retry) {
value = null
value = withContext(Dispatchers.IO) { SteamDeckRepo.fetch(sort, device, query, page) }
}
val last by androidx.compose.runtime.produceState<Int?>(null, sort, device, query, retry) {
value = null
value = withContext(Dispatchers.IO) { SteamDeckRepo.lastPage(sort, device, query) }
}
LaunchedEffect(last) { last?.let { if (page > it) page = it } }
val installPost: (SteamDeckRepo.Post) -> Unit = { post ->
if (progress.isEmpty()) {
progress[post.id] = 0
installScope.launch {
val outcome = try {
withContext(Dispatchers.IO) { install(app, post) { progress[post.id] = it } }
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
app.getString(R.string.boot_movie_repo_download_failed)
} finally {
progress.remove(post.id)
}
Toast.makeText(app, outcome ?: app.getString(R.string.boot_movie_repo_installed, post.title), Toast.LENGTH_LONG).show()
a.onBootMovieChanged()
}
}
}
val usePost: (SteamDeckRepo.Post) -> Unit = { post ->
scope.launch {
val name = withContext(Dispatchers.IO) { BootMovieLibrary.nameForRepo(app, post.id) }
if (name != null) a.onBootMovie(BootMovieLibrary.idOf(name))
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(bottom = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
Box(Modifier.weight(1f)) {
ListRow(tall = true, onClick = { searching = true }) {
Icon(Icons.Filled.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
Text(
query.ifEmpty { stringResource(R.string.boot_movie_repo_search) }, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f),
)
}
}
if (query.isNotEmpty()) SecondaryButton(stringResource(R.string.boot_movie_repo_clear), compact = true) { query = ""; page = 1 }
}
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TabChip(stringResource(R.string.boot_movie_repo_all), device == null) { device = null; page = 1 }
TabChip(stringResource(R.string.boot_movie_repo_deck), device == "steam_deck") { device = "steam_deck"; page = 1 }
TabChip(stringResource(R.string.boot_movie_repo_machine), device == "steam_machine") { device = "steam_machine"; page = 1 }
}
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SteamDeckRepo.Sort.values().forEach { option ->
TabChip(
stringResource(
when (option) {
SteamDeckRepo.Sort.TRENDING -> R.string.boot_movie_repo_trending
SteamDeckRepo.Sort.DOWNLOADS -> R.string.boot_movie_repo_downloads
SteamDeckRepo.Sort.LIKES -> R.string.boot_movie_repo_likes
SteamDeckRepo.Sort.NEWEST -> R.string.boot_movie_repo_newest
},
),
sort == option,
) { sort = option; page = 1 }
}
}
when (val r = result) {
null -> Text(stringResource(R.string.common_working), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp))
is SteamDeckRepo.Page.Failed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.boot_movie_repo_failed), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f))
SecondaryButton(stringResource(R.string.boot_movie_repo_retry), compact = true) { retry++ }
}
is SteamDeckRepo.Page.Ok -> {
if (r.posts.isEmpty()) Text(stringResource(R.string.boot_movie_repo_empty), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp))
r.posts.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
row.forEach { post ->
RepoCard(
post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(),
modifier = Modifier.weight(1f), onPreview = { previewing = post }, onInstall = { installPost(post) }, onUse = { usePost(post) },
)
}
if (row.size == 1) Box(Modifier.weight(1f))
}
}
RepoPager(page, last, hasMore = r.posts.size >= SteamDeckRepo.PAGE_SIZE, onPage = { page = it }, onJump = { jumping = true })
}
}
}
if (searching) TextEntryDialog(
stringResource(R.string.boot_movie_repo_search), query, number = false, confirm = stringResource(R.string.stores_search_open),
onSubmit = { value -> if (value != query) { query = value; page = 1 } }, onDismiss = { searching = false },
)
if (jumping) TextEntryDialog(
stringResource(R.string.boot_movie_repo_go_to_page), page.toString(), number = true, confirm = stringResource(R.string.common_ok),
onSubmit = { value -> value.toIntOrNull()?.let { n -> page = n.coerceIn(1, maxOf(1, last ?: n)) } }, onDismiss = { jumping = false },
)
previewing?.let { post ->
PreviewDialog(
post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(),
onInstall = { installPost(post) }, onUse = { usePost(post) }, onDismiss = { previewing = null },
)
}
}
/** Downloads [post] and puts it among the installed movies; returns a message when it failed, null when it worked. */
private fun install(app: android.content.Context, post: SteamDeckRepo.Post, onProgress: (Int) -> Unit): String? {
val folder = File(app.cacheDir, "boot-movie-download").apply { mkdirs() }
// Leftovers of a download that was cut short are cleared, and every download has a file of its own.
folder.listFiles()?.forEach { if (System.currentTimeMillis() - it.lastModified() > 60 * 60 * 1000L) it.delete() }
val file = File.createTempFile("movie-", ".webm", folder)
return try {
if (!SteamDeckRepo.download(post, file, onProgress)) return app.getString(R.string.boot_movie_repo_download_failed)
val cover = if (post.thumbnail.isNotEmpty()) SteamDeckRepo.bytes(post.thumbnail) else null
when (BootMovie.import(app, file, onProgress = onProgress, title = post.title, source = BootMovieLibrary.SOURCE_REPO, repoId = post.id, cover = cover, use = false)) {
is BootMovie.Import.Done -> null
BootMovie.Import.TooLong -> app.getString(R.string.boot_movie_too_long, BootMovie.MAX_CONVERT_SECONDS)
is BootMovie.Import.Unsupported -> app.getString(R.string.boot_movie_unsupported)
BootMovie.Import.Failed -> app.getString(R.string.boot_movie_import_failed)
}
} finally {
file.delete()
}
}
@Composable
private fun RepoCard(
post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier,
onPreview: () -> Unit, onInstall: () -> Unit, onUse: () -> Unit,
) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val shape = RoundedCornerShape(10.dp)
val thumbShape = RoundedCornerShape(7.dp)
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val previewLabel = stringResource(R.string.boot_movie_repo_preview)
Column(
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = modifier.clip(shape)
.background(if (inUse) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f))
.border(if (inUse) 2.dp else 1.dp, if (inUse) pal.signal else pal.line, shape).padding(6.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(thumbShape).background(Color(0xFF0E1218))
.glideBorder(hot, thumbShape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onPreview)
.controllerConfirm(onClick = onPreview)
.semantics { contentDescription = previewLabel },
) {
if (post.thumbnail.isNotEmpty()) AsyncImage(model = post.thumbnail, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
}
}
Text(post.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(repoInfo(post), fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse)
}
}
@Composable
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(" · ")
@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)
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,
)
}
/** A movie from SteamDeckRepo played straight from the site, so it can be checked before it is installed. */
@Composable
private fun PreviewDialog(
post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean,
onInstall: () -> Unit, onUse: () -> Unit, onDismiss: () -> Unit,
) {
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)
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
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)
}
}
@Composable
private fun MoviePreview(url: String, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val shape = RoundedCornerShape(10.dp)
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
var state by remember(url) { mutableIntStateOf(0) }
var ratio by remember(url) { mutableFloatStateOf(16f / 9f) }
val player = remember(url) { arrayOfNulls<MediaPlayer>(1) }
DisposableEffect(url) {
onDispose {
runCatching { player[0]?.release() }
player[0] = null
}
}
val toggle = { runCatching { player[0]?.let { if (it.isPlaying) it.pause() else it.start() } }; Unit }
Box(
contentAlignment = Alignment.Center,
modifier = modifier.clip(shape).background(Color.Black)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle),
) {
AndroidView(
modifier = Modifier.aspectRatio(ratio),
factory = { viewContext ->
TextureView(viewContext).apply {
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
val mp = MediaPlayer()
player[0] = mp
try {
mp.setDataSource(viewContext, Uri.parse(url), mapOf("User-Agent" to "DroidDeck"))
mp.setSurface(Surface(texture))
mp.isLooping = true
mp.setOnVideoSizeChangedListener { _, w, h -> if (w > 0 && h > 0) ratio = w.toFloat() / h }
mp.setOnPreparedListener { it.start(); state = 1 }
mp.setOnErrorListener { _, _, _ -> state = 2; true }
mp.prepareAsync()
} catch (e: Exception) {
state = 2
}
}
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) = Unit
override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean {
runCatching { player[0]?.release() }
player[0] = null
return true
}
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) = Unit
}
}
},
)
if (state == 0) Text(stringResource(R.string.common_working), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f))
if (state == 2) Text(stringResource(R.string.boot_movie_repo_preview_failed), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f), modifier = Modifier.padding(12.dp))
}
}
/** A dialog with one text box, for a search term or a page number; the Android keyboard comes up with it. */
@Composable
private fun TextEntryDialog(
title: String, initial: String, number: Boolean, confirm: String, onSubmit: (String) -> Unit, onDismiss: () -> Unit,
) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var text by remember { mutableStateOf(initial) }
val submit = { onSubmit(text.trim()); close() }
AppDialog(shown, close, "boot-movie-entry", wide = false, maxWidth = 460.dp) {
DialogTitleRow(title, close)
AdbTextField(
value = text, onValueChange = { text = it }, label = "", keyboardType = if (number) KeyboardType.Number else KeyboardType.Text,
imeAction = ImeAction.Done, placeholder = title, focusOnShow = true, onDone = { submit() },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton(stringResource(R.string.common_cancel), modifier = Modifier.weight(1f), onClick = close)
SecondaryButton(confirm, modifier = Modifier.weight(1f)) { submit() }
}
}
}
/** Page buttons around the current page, which opens a box to type another page into; first and last page show their numbers. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RepoPager(page: Int, last: Int?, hasMore: Boolean, onPage: (Int) -> Unit, onJump: () -> Unit) {
val reach = last ?: if (hasMore) Int.MAX_VALUE else page
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
SecondaryButton(stringResource(R.string.boot_movie_repo_previous), enabled = page > 1, compact = true) { onPage(page - 1) }
if (page - 3 > 1) SecondaryButton("1", compact = true) { onPage(1) }
for (n in maxOf(1, page - 3) until page) SecondaryButton("$n", compact = true) { onPage(n) }
TabChip("$page", true, Modifier.widthIn(min = 52.dp).heightIn(min = 44.dp), onJump)
for (n in page + 1..minOf(page + 3, reach)) SecondaryButton("$n", compact = true) { onPage(n) }
if (last != null && page + 3 < last) SecondaryButton("$last", compact = true) { onPage(last) }
SecondaryButton(stringResource(R.string.boot_movie_repo_next), enabled = page < reach, compact = true) { onPage(page + 1) }
}
}
@@ -59,16 +59,22 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.util.Locale
internal val Shape14 = RoundedCornerShape(14.dp)
@Composable
internal fun headingUppercase(text: String): String =
text.uppercase(LocalConfiguration.current.locales[0] ?: Locale.ROOT)
@Composable
internal fun Eyebrow(t: String) {
val pal = LocalPalette.current
@@ -76,7 +82,7 @@ internal fun Eyebrow(t: String) {
LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal))
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
}
}
@@ -88,7 +94,7 @@ internal fun Eyebrow(t: String) {
internal fun SectionTitle(t: String, detail: String?) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) {
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground)
Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line))
}
@@ -0,0 +1,128 @@
package com.droiddeck.launcher.ui
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import java.text.Collator
internal enum class GestureActionGroup(@StringRes val title: Int) {
MOUSE(R.string.gesture_group_mouse), APP(R.string.gesture_group_app),
NAVIGATION(R.string.gesture_group_navigation), LETTERS(R.string.gesture_group_letters),
NUMBERS(R.string.gesture_group_numbers), FUNCTION(R.string.gesture_group_function),
MODIFIERS(R.string.gesture_group_modifiers), NUMPAD(R.string.gesture_group_numpad),
SYMBOLS(R.string.gesture_group_symbols),
}
internal data class GestureAction(val id: String, val title: String, val group: GestureActionGroup)
internal fun gestureActionGroup(id: String): GestureActionGroup {
if (id == "show_keyboard") return GestureActionGroup.APP
if (id in setOf("left_click", "right_click", "middle_click", "left_click_drag", "right_click_drag",
"middle_mouse_pan", "inverted_middle_mouse_pan", "scroll_wheel")) return GestureActionGroup.MOUSE
val key = id.removePrefix("key_")
return when {
key.startsWith("KP_") -> GestureActionGroup.NUMPAD
key.length == 1 && key[0] in 'A'..'Z' -> GestureActionGroup.LETTERS
key.length == 1 && key[0].isDigit() -> GestureActionGroup.NUMBERS
key.matches(Regex("F(?:[1-9]|1[0-2])")) -> GestureActionGroup.FUNCTION
key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_") || key.endsWith("_LOCK") -> GestureActionGroup.MODIFIERS
key in setOf("MINUS", "EQUAL", "BRACKET_LEFT", "BRACKET_RIGHT", "SEMICOLON", "APOSTROPHE",
"GRAVE", "TILDE", "BACKSLASH", "COMMA", "PERIOD", "SLASH") || id == "plus_minus" -> GestureActionGroup.SYMBOLS
else -> GestureActionGroup.NAVIGATION
}
}
internal fun filterGestureActions(actions: List<GestureAction>, query: String, group: GestureActionGroup?): List<GestureAction> {
val words = query.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
val collator = Collator.getInstance()
return actions.filter { action ->
(group == null || action.group == group) && words.all { word ->
action.title.contains(word, ignoreCase = true) || action.id.removePrefix("key_").replace('_', ' ').contains(word, ignoreCase = true)
}
}.sortedWith(compareBy<GestureAction> { it.group.ordinal }.thenComparator { a, b ->
// Function keys follow their numeric sequence; names use the UI locale.
if (a.group == GestureActionGroup.FUNCTION) {
a.id.removePrefix("key_F").toInt().compareTo(b.id.removePrefix("key_F").toInt())
} else collator.compare(a.title, b.title)
}.thenBy { it.id })
}
@Composable
internal fun gestureActionTitle(action: String): String {
val named = when (action) {
"left_click" -> R.string.gesture_left_click
"right_click" -> R.string.gesture_right_click
"middle_click" -> R.string.gesture_middle_click
"show_keyboard" -> R.string.gesture_keyboard
"left_click_drag" -> R.string.gesture_left_drag
"right_click_drag" -> R.string.gesture_right_drag
"middle_mouse_pan" -> R.string.gesture_middle_pan
"inverted_middle_mouse_pan" -> R.string.gesture_middle_pan_inverted
"arrow_keys" -> R.string.gesture_arrows
"inverted_arrow_keys" -> R.string.gesture_arrows_inverted
"inverted_wasd" -> R.string.gesture_wasd_inverted
"scroll_wheel" -> R.string.gesture_scroll
else -> null
}
if (named != null) return stringResource(named)
return when (action) {
"wasd" -> "WASD"
"plus_minus" -> "+ / −"
"page_up_down" -> stringResource(R.string.gesture_key_page_up) + " / " + stringResource(R.string.gesture_key_page_down)
else -> gestureKeyTitle(action.removePrefix("key_"))
}
}
@Composable
private fun gestureKeyTitle(key: String): String {
if (key.startsWith("KP_")) return stringResource(R.string.gesture_key_numpad, gestureKeyTitle(key.removePrefix("KP_")))
val named = when (key) {
"ESC" -> R.string.gesture_key_escape
"BKSP" -> R.string.gesture_key_backspace
"ENTER" -> R.string.gesture_key_enter
"TAB" -> R.string.gesture_key_tab
"SPACE" -> R.string.gesture_key_space
"DEL" -> R.string.gesture_key_delete
"INSERT" -> R.string.gesture_key_insert
"HOME" -> R.string.gesture_key_home
"END" -> R.string.gesture_key_end
"PRIOR" -> R.string.gesture_key_page_up
"NEXT" -> R.string.gesture_key_page_down
"PRTSCN" -> R.string.gesture_key_print_screen
"UP" -> R.string.gesture_key_up
"DOWN" -> R.string.gesture_key_down
"LEFT" -> R.string.gesture_key_left_arrow
"RIGHT" -> R.string.gesture_key_right_arrow
"CAPS_LOCK" -> R.string.gesture_key_caps_lock
"NUM_LOCK" -> R.string.gesture_key_num_lock
"SCROLL_LOCK" -> R.string.gesture_key_scroll_lock
else -> null
}
if (named != null) return stringResource(named)
if (key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_")) {
val name = stringResource(when (key.substringBefore('_')) {
"CTRL" -> R.string.pckb_key_ctrl
"SHIFT" -> R.string.gesture_key_shift
else -> R.string.pckb_key_alt
})
return stringResource(if (key.endsWith("_L")) R.string.gesture_key_left else R.string.gesture_key_right, name)
}
return when (key) {
"MINUS", "SUBTRACT" -> "−"
"EQUAL" -> "="
"ADD" -> "+"
"MULTIPLY" -> "×"
"DIVIDE", "SLASH" -> "/"
"BRACKET_LEFT" -> "["
"BRACKET_RIGHT" -> "]"
"SEMICOLON" -> ";"
"APOSTROPHE" -> "'"
"GRAVE" -> "`"
"TILDE" -> "~"
"BACKSLASH" -> "\\"
"COMMA" -> ","
"PERIOD" -> "."
else -> key.replace('_', ' ')
}
}
@@ -0,0 +1,35 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.input.TouchscreenGestures.Gesture
@Composable
fun GestureFeedback(gesture: Gesture?) {
Box(Modifier.fillMaxSize()) {
if (gesture != null) Text(stringResource(when (gesture) {
Gesture.TAP -> R.string.gesture_tap
Gesture.DRAG -> R.string.gesture_drag
Gesture.LONG_PRESS -> R.string.gesture_long_press
Gesture.DOUBLE_TAP -> R.string.gesture_double_tap
Gesture.TWO_FINGER_TAP -> R.string.gesture_two_tap
Gesture.TWO_FINGER_DRAG -> R.string.gesture_two_drag
Gesture.TWO_FINGER_HOLD -> R.string.gesture_two_hold
Gesture.PINCH -> R.string.gesture_pinch
Gesture.THREE_FINGER_TAP -> R.string.gesture_three_tap
Gesture.THREE_FINGER_DRAG -> R.string.gesture_three_drag
Gesture.THREE_FINGER_HOLD -> R.string.gesture_three_hold
}),
Modifier.align(Alignment.TopCenter).padding(top = 24.dp)
.background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -1,9 +1,11 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.text.format.Formatter
import android.speech.tts.TextToSpeech
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -70,6 +72,7 @@ import com.droiddeck.launcher.handtracking.Advice
import com.droiddeck.launcher.handtracking.BodyPose
import com.droiddeck.launcher.handtracking.HandCamera
import com.droiddeck.launcher.handtracking.HandTracking
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.handtracking.Landmark
import com.droiddeck.launcher.handtracking.TrackingCameras
import kotlinx.coroutines.delay
@@ -186,11 +189,27 @@ internal fun HandCameraRows(host: MenuHost, track: (String) -> Modifier, inSessi
})
!s.permitted -> ActionRow(stringResource(R.string.hand_camera_access), stringResource(R.string.hand_camera_access_hint),
stringResource(R.string.hand_camera_allow_button), { ask.launch(Manifest.permission.CAMERA) })
!s.downloaded -> TrackingPackRow()
!inSession -> ActionRow(stringResource(R.string.hand_camera_placement), stringResource(R.string.hand_camera_placement_hint),
stringResource(R.string.hand_camera_check), { HandTracking.requestCheck() })
}
}
@Composable
private fun TrackingPackRow() {
val context = LocalContext.current
val pack by TrackingPack.state.collectAsState()
val size = Formatter.formatShortFileSize(context, TrackingPack.size)
when (val now = pack) {
is AddonPack.State.Downloading -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), {}, progress = now.progress)
AddonPack.State.Failed -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_failed), stringResource(R.string.tracking_pack_retry), { TrackingPack.ensure(context) })
else -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), { TrackingPack.ensure(context) })
}
}
@Composable
private fun PlacementCheck(settings: HandTracking.Settings, play: Boolean, onPlay: () -> Unit, onClose: () -> Unit) {
val context = LocalContext.current
@@ -66,6 +66,22 @@ fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () ->
)
}
@Composable
fun RuntimeActionsDialog(onRepair: () -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.runtime_actions_title)) },
text = { Text(stringResource(R.string.runtime_actions_text)) },
confirmButton = { TextButton(onClick = onRepair) { Text(stringResource(R.string.runtime_repair)) } },
dismissButton = {
Row {
TextButton(onClick = onRemove) { Text(stringResource(R.string.store_remove)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
}
},
)
}
@Composable
fun ResetSettingsDialog(onConfirm: (gameProfiles: Boolean) -> Unit, onDismiss: () -> Unit) {
var gameProfiles by remember { mutableStateOf(false) }
@@ -139,7 +139,7 @@ class ModeSettingsActions(
val onBootMovie: (String) -> Unit = {},
val onBootMovieSound: (Boolean) -> Unit = {},
val onImportBootMovie: () -> Unit = {},
val onRemoveBootMovie: () -> Unit = {},
val onBootMovieChanged: () -> Unit = {},
val onWifiDiscovery: (Boolean) -> Unit = {},
val onWifiDiscoverySettings: () -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
@@ -250,7 +250,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
@@ -467,41 +467,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
onPick = a.onSteamClientArch,
)
}
if (s.bootMovie != null) SettingsGroup(stringResource(R.string.boot_movie)) {
ChoiceRow(
host, "boot-movie", stringResource(R.string.boot_movie_video), stringResource(R.string.common_applies_next_session),
buildList {
add(BootMovie.DROIDDECK_30 to stringResource(R.string.boot_movie_droiddeck, 30))
add(BootMovie.DROIDDECK_60 to stringResource(R.string.boot_movie_droiddeck, 60))
s.bootMovieCustomName?.let { add(BootMovie.CUSTOM to it) }
}, s.bootMovie,
note = stringResource(R.string.boot_movie_note), onPick = a.onBootMovie,
)
ToggleRow(
host, "boot-movie-sound", stringResource(R.string.boot_movie_sound),
stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound,
)
SettingsRow(
stringResource(R.string.boot_movie_custom),
stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS),
) {
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
when {
s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress)
s.bootMovieImporting -> stringResource(R.string.common_working)
else -> stringResource(R.string.boot_movie_import)
},
enabled = !s.bootMovieImporting,
progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f },
onClick = a.onImportBootMovie,
)
if (s.bootMovieCustomName != null) SecondaryButton(
stringResource(R.string.boot_movie_remove), enabled = !s.bootMovieImporting, onClick = a.onRemoveBootMovie,
)
}
}
}
if (s.bootMovie != null) BootMovieGroup(s, a, host)
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
@@ -122,6 +122,7 @@ fun LoadingOverlay(
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
endedDetail: String? = null,
onRetry: (() -> Unit)? = null,
onRepair: (() -> Unit)? = null,
onShareLogs: (() -> Unit)? = null,
onClose: (() -> Unit)? = null,
flood: Color? = null,
@@ -187,7 +188,7 @@ fun LoadingOverlay(
textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp),
)
if (ended) EndedActions(onRetry, onShareLogs, onClose)
if (ended) EndedActions(onRetry, onRepair, onShareLogs, onClose)
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
@@ -200,7 +201,7 @@ fun LoadingOverlay(
}
@Composable
private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
private fun EndedActions(onRetry: (() -> Unit)?, onRepair: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
val first = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
@@ -209,7 +210,11 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) {
var firstUsed = false
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
if (onRepair != null) PrimaryButton(stringResource(R.string.runtime_repair), modifier = claim(), onClick = onRepair)
if (onRetry != null) {
if (onRepair != null) SecondaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
else PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
}
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(),
progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs)
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
@@ -245,6 +245,7 @@ class DrawerActions(
val onLowLatencyController: (Boolean) -> Unit = {},
val onLowLatencyTouch: (Boolean) -> Unit = {},
val onTouch: (String) -> Unit,
val onGestureSettings: () -> Unit = {},
val onFexPreset: (String) -> Unit,
val onUpscaler: (Int) -> Unit = {},
val onUpscaleSharpness: (Int) -> Unit = {},
@@ -500,8 +501,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType)
}
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
ActionRow(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_settings_hint),
stringResource(R.string.ctrl_edit), a.onGestureSettings,
modifier = focus.track(page, "gesture-settings"))
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
@@ -648,8 +648,8 @@ fun MultiRow(
}
@Composable
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null) {
SettingsRow(label, hint) { SecondaryButton(button, progress = progress, onClick = onClick) }
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null, modifier: Modifier = Modifier) {
SettingsRow(label, hint) { SecondaryButton(button, modifier = modifier, progress = progress, onClick = onClick) }
}
@Composable
@@ -29,13 +29,13 @@ import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.glasses.CalibrationDistance
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.glasses.Glasses
import com.droiddeck.launcher.glasses.SpatialCalibrationProgress
import com.droiddeck.launcher.glasses.SpatialTracking
import com.droiddeck.launcher.glasses.SpatialTrackingStatus
import com.droiddeck.launcher.glasses.StereoCalibrator
import kotlinx.coroutines.delay
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.Mat
import org.opencv.core.Size
@@ -95,7 +95,7 @@ class SpatialTrackingActivity : ComponentActivity() {
}
override fun onPause() { SpatialTracking.diagnostics(false);super.onPause() }
private fun printBoard() {
if(!OpenCVLoader.initLocal()) return
if(!TrackingPack.loadNative(this)) return
val c=StereoCalibrator(this);val mat=Mat()
c.board.generateImage(Size(1500.0,2100.0),mat,0,1)
val bitmap=Bitmap.createBitmap(1500,2100,Bitmap.Config.ARGB_8888)
@@ -781,17 +781,18 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack:
}
@Composable
private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
internal fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
val ctx = LocalContext.current
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val busy = sessionRunning || StorageState.cleaning
var confirm by remember { mutableStateOf<ComponentUsage?>(null) }
var confirmAll by remember { mutableStateOf(false) }
val unused = report.unusedComponents
SubHeader(stringResource(R.string.storage_components),
stringResource(R.string.storage_tools_detail, gb(report.components.sumOf { it.bytes }), gb(unused.sumOf { it.bytes })), onBack) {
if (unused.isNotEmpty()) PrimaryButton(stringResource(R.string.storage_remove_unused, gb(unused.sumOf { it.bytes })), compact = true, enabled = !busy) {
StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { }
confirmAll = true
}
}
ActionStatus()
@@ -824,6 +825,19 @@ private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBac
stringResource(if (c.packageFile != null) R.string.storage_component_confirm else R.string.storage_original_confirm, gb(c.bytes)),
stringResource(R.string.storage_remove), onConfirm = { StorageState.removeComponent(ctx, c) }, onDismiss = { confirm = null })
}
if (confirmAll) {
val total = gb(unused.sumOf { it.bytes })
val lines = unused.map { "• " + componentLabel(it) + " " + gb(it.bytes) }.joinToString("\n")
val warning = stringResource(
if (unused.any { it.packageFile != null }) R.string.storage_component_confirm else R.string.storage_original_confirm, total)
ConfirmDialog(
stringResource(R.string.storage_remove_unused, total),
lines + "\n\n" + warning,
stringResource(R.string.storage_remove),
onConfirm = { StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { } },
onDismiss = { confirmAll = false },
)
}
}
@Composable
@@ -125,7 +125,8 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
if (store == Store.XBOX) {
SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) {
com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed)
runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) }
.onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." }
}
SecondaryButton("Manage content", enabled = download == null) {
com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed)
@@ -25,6 +25,7 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.geometry.Size
@@ -483,7 +484,7 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) {
}
}
internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<FrontEndState>, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk =
internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<FrontEndState>, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color, onArrange: () -> Unit): StepAsk =
StepAsk(
anchor = at, side = StepSide.Left, accent = accent, items = 2, maxWidth = 440.dp,
handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) },
@@ -494,6 +495,12 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val st = s.value
SettingsRow(stringResource(R.string.stores_tabs_arrange), null) {
RoundAction(Icons.Filled.SwapHoriz, stringResource(R.string.stores_tabs_arrange), size = 36.dp) {
StoresMotion.fold()
onArrange()
}
}
SettingsRow(stringResource(R.string.setup_stores_open_on), null, stacked = true) {
Box(Modifier.focusRequester(first).focusGroup()) { SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
@@ -506,6 +513,9 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
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)
}
SettingsRow(stringResource(R.string.gog_achievements), stringResource(R.string.gog_achievements_hint)) {
ToggleSwitch(gogAchievements, label = stringResource(R.string.gog_achievements)) {
gogAchievements = it
@@ -10,6 +10,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.outlined.Folder
@@ -24,6 +25,7 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
@@ -37,6 +39,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import android.view.KeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.drawWithContent
@@ -65,6 +68,8 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import kotlin.math.roundToInt
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
@@ -116,6 +121,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -137,7 +143,9 @@ import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreTab
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.arrangeStoreTabs
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.formatBytes
@@ -194,12 +202,53 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
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 arranging by remember { mutableStateOf(false) }
val shownStores = tabs.filter { it.shown }.map { it.store }
fun commitTabs() {
SessionPrefs.setStoresTabOrder(ctx, tabs.map { it.store.id })
SessionPrefs.setStoresTabHidden(ctx, tabs.filterNot { it.shown }.map { it.store.id }.toSet())
}
fun shiftTab(store: Store, delta: Int) {
val from = tabs.indexOfFirst { it.store == store }
val to = from + delta
if (from < 0 || to !in tabs.indices) return
tabs.add(to, tabs.removeAt(from))
commitTabs()
}
fun moveTab(store: Store, to: Int) {
val from = tabs.indexOfFirst { it.store == store }
if (from < 0) return
val target = to.coerceIn(0, tabs.lastIndex)
if (target == from) return
tabs.add(target, tabs.removeAt(from))
commitTabs()
}
fun toggleTab(store: Store) {
val i = tabs.indexOfFirst { it.store == store }
if (i < 0) return
val show = !tabs[i].shown
if (!show && tabs.count { it.shown } <= 1) return
tabs[i] = tabs[i].copy(shown = show)
commitTabs()
}
LaunchedEffect(tabs.map { it.store to it.shown }, chip) {
val picked = Store.byId(chip) ?: return@LaunchedEffect
if (tabs.none { it.store == picked && it.shown }) chip = tabs.firstOrNull { it.shown }?.store?.id ?: chip
}
var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") }
var query by rememberSaveable { mutableStateOf("") }
var openGame by rememberSaveable { mutableStateOf<String?>(null) }
val liveState = androidx.compose.runtime.rememberUpdatedState(s)
LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } }
val store = Store.byId(chip)
LaunchedEffect(tabs.toList()) {
if (chip != DOWNLOADS && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS
}
val shownTab = tabFor(store, tab)
val pane = PaneKey(
chip, if (store == null) "" else shownTab,
@@ -246,7 +295,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
}
val pal = LocalPalette.current
fun cycleStore(step: Int) {
val ids = Store.entries.map { it.id }
val ids = tabs.filter { it.shown }.map { it.store.id }
if (ids.isEmpty()) return
val at = ids.indexOf(chip)
pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size])
}
@@ -254,6 +304,16 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
if (chip != st.id) pickChip(st.id) else move("first")
}
BackHandler(enabled = openGame != null) { closeGame() }
BackHandler(enabled = arranging) { arranging = false }
LaunchedEffect(arranging) {
val focus = ff ?: return@LaunchedEffect
if (!arranging || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
repeat(45) {
androidx.compose.runtime.withFrameNanos { }
val req = tabs.firstOrNull()?.let { focus.items["storechip:${it.store.id}"] } ?: return@repeat
if (runCatching { req.requestFocus() }.isSuccess) return@LaunchedEffect
}
}
val scroll = rememberScrollState()
val detailScroll = rememberScrollState()
LaunchedEffect(chip, tab) { scroll.scrollTo(0); grid.scrollToItem(0) }
@@ -329,14 +389,32 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
.bumpers(
onPrevious = { if (openGame == null) cycleStore(-1) },
onNext = { if (openGame == null) cycleStore(1) },
onPrevious = {
val picked = Store.byId(focusedChip)
if (arranging) { if (picked != null) shiftTab(picked, -1) }
else if (openGame == null) cycleStore(-1)
},
onNext = {
val picked = Store.byId(focusedChip)
if (arranging) { if (picked != null) shiftTab(picked, 1) }
else if (openGame == null) cycleStore(1)
},
),
) {
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
StoreTheme(Store.byId(focusedChip ?: chip)) {
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, _ -> openModes(st) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) }
Rise(0) {
StoreChips(
chip, focusedChip, s.storeDownloadsActive, tabs, arranging,
onStore = { st -> openModes(st) },
onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
onFocusChip = { focusedChip = it },
onArrangeDone = { arranging = false },
onMoveTab = { st, to -> moveTab(st, to) },
onToggleTab = { toggleTab(it) },
onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal) { arranging = true }) },
)
}
}
}
val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() }
@@ -547,55 +625,148 @@ private fun SignInFloodLayer(hostAt: Offset) {
}
private class ChipDrag {
var store by mutableStateOf<Store?>(null)
var from by mutableIntStateOf(-1)
var target by mutableIntStateOf(-1)
var x by mutableFloatStateOf(0f)
}
@Composable
private fun StoreDot(store: Store, signedIn: Boolean) {
val c = sourceColours(store.id)
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)) }
}
@Composable
private fun RowScope.ArrangeStoreChip(
tab: StoreTab, index: Int, last: Int, gapPx: Float, drag: ChipDrag,
onFocusChip: (String?) -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit,
) {
val store = tab.store
val box = remember(store) { mutableFloatStateOf(0f) }
val pitch = box.floatValue + gapPx
val held = drag.store == store
val lift by animateFloatAsState(if (held) 1f else 0f, Motion.sp(0.7f, Spring.StiffnessMediumLow), label = "chipLift")
val shift by animateFloatAsState(
when {
drag.store == null || held -> 0f
drag.from < index && drag.target >= index -> -pitch
drag.from > index && drag.target <= index -> pitch
else -> 0f
},
Motion.sp(0.6f, Spring.StiffnessMediumLow), label = "chipShift",
)
val pill = remember { RoundedCornerShape(99.dp) }
val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true
fun reset() {
drag.store = null
drag.from = -1
drag.target = -1
drag.x = 0f
}
fun commit() {
val from = drag.from
val to = drag.target
reset()
if (from >= 0 && to >= 0 && to != from) onMoveTab(store, to)
}
StoreChip(
label = store.shortLabel, on = false, under = false, key = store.id, mark = sourceColours(store.id).dot, lift = lift,
modifier = Modifier.weight(1f)
.zIndex(if (held) 2f else 0f)
.onSizeChanged { box.floatValue = it.width.toFloat() }
.graphicsLayer {
translationX = if (held) drag.x else shift
shadowElevation = 14.dp.toPx() * lift
shape = pill
clip = false
}
.pointerInput(store, index) {
detectDragGestures(
onDragStart = {
drag.store = store
drag.from = index
drag.target = index
drag.x = 0f
},
onDragEnd = { commit() },
onDragCancel = { reset() },
) { change, amount ->
change.consume()
val step = box.floatValue + gapPx
if (step <= 0f) return@detectDragGestures
drag.x = (drag.x + amount.x).coerceIn(-drag.from * step, (last - drag.from) * step)
drag.target = (drag.from + (drag.x / step).roundToInt()).coerceIn(0, last)
}
}
.alpha(if (tab.shown) 1f else 0.32f),
lead = { StoreDot(store, signedIn) },
onFocused = onFocusChip,
) { onToggleTab(store) }
}
@Composable
private fun StoreChips(
selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit,
onFocusChip: (String?) -> Unit,
selected: String, focused: String?, active: Int, tabs: List<StoreTab>, arranging: Boolean,
onStore: (Store) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, onFocusChip: (String?) -> Unit,
onArrangeDone: () -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit,
) {
val pal = LocalPalette.current
val keys = Store.entries.map { it.id } + DOWNLOADS
val keys = tabs.filter { it.shown }.map { it.store.id } + DOWNLOADS
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, Rect>() }
val rootBounds = remember { HashMap<String, Rect>() }
val highlighted = focused ?: selected
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode
val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard
val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard && !arranging
val tint = androidx.compose.animation.animateColorAsState(
Store.byId(highlighted)?.let { sourceColours(it.id).dot } ?: pal.signal, Motion.tw(160), label = "chipTint",
)
val gap = 8.dp
val gapPx = with(androidx.compose.ui.platform.LocalDensity.current) { gap.toPx() }
val drag = remember { ChipDrag() }
Box(Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
for (store in Store.entries) {
val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true
StoreChip(
label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot,
modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() },
lead = {
val c = sourceColours(store.id)
Box(
Modifier.size(12.dp)
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot)
.background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
)
},
description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out),
onFocused = onFocusChip,
) { onStore(store, rootBounds[store.id]) }
Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
for (tab in tabs) {
val store = tab.store
if (arranging) {
val index = tabs.indexOfFirst { it.store == store }
key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) }
} else if (tab.shown) {
val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true
key(store) {
StoreChip(
label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot,
modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() },
lead = { StoreDot(store, signedIn) },
description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out),
onFocused = onFocusChip,
) { onStore(store) }
}
}
}
StoreChip(
label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
modifier = Modifier.onGloballyPositioned {
bounds[DOWNLOADS] = it.boundsInParent()
StoresMotion.downloadsChip = it.boundsInRoot()
},
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
trail = if (active > 0) { { CountPill(active) } } else null,
description = stringResource(R.string.stores_tab_downloads),
onFocused = onFocusChip,
) { onDownloads() }
var cogAt by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) {
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) }
if (arranging) {
IconChip(Icons.Filled.Check, stringResource(R.string.stores_tabs_arrange_done)) { onArrangeDone() }
} else {
StoreChip(
label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
modifier = Modifier.onGloballyPositioned {
bounds[DOWNLOADS] = it.boundsInParent()
StoresMotion.downloadsChip = it.boundsInRoot()
},
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
trail = if (active > 0) { { CountPill(active) } } else null,
description = stringResource(R.string.stores_tab_downloads),
onFocused = onFocusChip,
) { onDownloads() }
var cogAt by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) {
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) }
}
}
}
ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown)
@@ -605,7 +776,7 @@ private fun StoreChips(
@Composable
private fun StoreChip(
label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
trail: (@Composable () -> Unit)? = null, description: String? = null,
trail: (@Composable () -> Unit)? = null, description: String? = null, lift: Float = 0f,
onFocused: (String?) -> Unit = {}, onClick: () -> Unit,
) {
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
@@ -630,10 +801,10 @@ private fun StoreChip(
if (f.isFocused && inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) onFocused(key)
else if (!f.isFocused) onFocused(null)
}
.heightIn(min = 44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(shape)
.background(if (under) Color.Transparent else colors.surface)
.heightIn(min = 44.dp).graphicsLayer { scaleX = scale * (1f + 0.08f * lift); scaleY = scale * (1f + 0.08f * lift) }.clip(shape)
.background(if (under) Color.Transparent else lerp(colors.surface, pal.signal, 0.18f * lift))
.shownBar(on, mark)
.glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line)
.glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else lerp(pal.line, pal.signal, lift))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier)
@@ -961,7 +1132,7 @@ private suspend fun <T> CompletableFuture<T>.await(): T =
@Composable
private fun SectionLabel(title: String) {
Text(
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp),
)
}
@@ -996,6 +1167,7 @@ internal fun ModeHeader(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var searching by remember { mutableStateOf(query.isNotEmpty()) }
var focusOnOpen by remember { mutableStateOf(false) }
val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen")
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) {
@@ -1010,11 +1182,11 @@ internal fun ModeHeader(
Box(Modifier.width(w).graphicsLayer { alpha = (open * 1.6f).coerceAtMost(1f) }) {
AdbTextField(
value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search,
placeholder = placeholder, compact = true, focusOnShow = true,
onFocusChange = { focused -> if (!focused && query.isEmpty()) searching = false },
placeholder = placeholder, compact = true, focusOnShow = focusOnOpen,
onFocusChange = { focused -> if (!focused && query.isEmpty()) { searching = false; focusOnOpen = false } },
)
}
} else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true }
} else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true; focusOnOpen = true }
}
trailing()
}
@@ -0,0 +1,345 @@
package com.droiddeck.launcher.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Mouse
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.droiddeck.launcher.R
import com.droiddeck.launcher.input.GestureKeys
import com.droiddeck.launcher.input.GesturePrefs
import com.droiddeck.launcher.input.TouchGestureConfig
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_LEFT_CLICK
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_RIGHT_CLICK
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_MIDDLE_CLICK
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_SHOW_KEYBOARD
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_KEY_TILDE
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.PAN_ACTIONS
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ZOOM_ACTIONS
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_HOLD
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_CLICK
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_DRAG_MOVEMENT_DIRECT
import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_DRAG_MOVEMENT_RELATIVE
@Composable
fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit) {
val context = LocalContext.current
// Keep an open editor attached to the game it was opened for.
val targetGame = remember { game }
val initialDefaults = remember { GesturePrefs.read(context) }
val initialGame = remember { targetGame?.takeIf { GesturePrefs.hasOverride(context, it) }?.let { GesturePrefs.read(context, it) } }
var defaults by remember { mutableStateOf(initialDefaults) }
var gameDraft by remember { mutableStateOf(initialGame) }
var forGame by rememberSaveable { mutableStateOf(targetGame != null) }
val config = if (forGame) gameDraft ?: defaults else defaults
val change: (TouchGestureConfig) -> Unit = { if (forGame) gameDraft = it else defaults = it }
var tab by rememberSaveable { mutableIntStateOf(0) }
val tabs = listOf(R.string.gesture_tab_one, R.string.gesture_tab_two, R.string.gesture_tab_three, R.string.gesture_tab_options)
val mouse = listOf(ACTION_LEFT_CLICK, ACTION_RIGHT_CLICK, ACTION_MIDDLE_CLICK)
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,
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)) }
Spacer(Modifier.width(12.dp))
Button(onClick = {
if (defaults != initialDefaults) GesturePrefs.save(context, null, defaults)
if (targetGame != null && gameDraft != initialGame) {
gameDraft?.let { GesturePrefs.save(context, targetGame, it) } ?: GesturePrefs.clear(context, targetGame)
}
onSaved()
}, 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,
unselectedContentColor = p.onSurfaceVariant, modifier = Modifier.testTag("gesture-tab-$index"))
}
}
key(tab) {
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
when (tab) {
0 -> {
GestureSection(R.string.gesture_tab_one, R.string.gesture_one_hint)
GestureCard(R.string.gesture_tap, config.tapEnabled, { change(config.copy(tapEnabled = it)) }) {
GestureActionField("tap", R.string.gesture_tap, config.tapAction, actions) { change(config.copy(tapAction = it)) }
}
GestureCard(R.string.gesture_drag, config.dragEnabled, { change(config.copy(dragEnabled = it)) }) {
GestureActionField("drag", R.string.gesture_drag, config.dragAction, PAN_ACTIONS) { change(config.copy(dragAction = it)) }
}
GestureCard(R.string.gesture_long_press, config.longPressEnabled, { change(config.copy(longPressEnabled = it)) }) {
GestureActionField("long-press", R.string.gesture_long_press, config.longPressAction, actions) { change(config.copy(longPressAction = it)) }
GestureHoldOptions(config.longPressAction, config.longPressMouseBehavior, config.longPressDelay, { change(config.copy(longPressMouseBehavior = it)) }, { change(config.copy(longPressDelay = it)) })
}
GestureCard(R.string.gesture_double_tap, config.doubleTapEnabled, { change(config.copy(doubleTapEnabled = it)) }) {
GestureDelay(stringResource(R.string.gesture_double_delay), config.doubleTapDelay) { change(config.copy(doubleTapDelay = it)) }
}
}
1 -> {
GestureSection(R.string.gesture_tab_two, R.string.gesture_two_hint)
GestureCard(R.string.gesture_two_tap, config.twoFingerTapEnabled, { change(config.copy(twoFingerTapEnabled = it)) }) {
GestureActionField("two-tap", R.string.gesture_two_tap, config.twoFingerTapAction, actions) { change(config.copy(twoFingerTapAction = it)) }
}
GestureCard(R.string.gesture_two_drag, config.twoFingerDragEnabled, { change(config.copy(twoFingerDragEnabled = it)) }) {
GestureActionField("two-drag", R.string.gesture_two_drag, config.twoFingerDragAction, PAN_ACTIONS) { change(config.copy(twoFingerDragAction = it)) }
}
GestureCard(R.string.gesture_two_hold, config.twoFingerHoldEnabled, { change(config.copy(twoFingerHoldEnabled = it)) }) {
GestureActionField("two-hold", R.string.gesture_two_hold, config.twoFingerHoldAction, actions) { change(config.copy(twoFingerHoldAction = it)) }
GestureHoldOptions(config.twoFingerHoldAction, config.twoFingerHoldMouseBehavior, config.twoFingerHoldDelay, { change(config.copy(twoFingerHoldMouseBehavior = it)) }, { change(config.copy(twoFingerHoldDelay = it)) })
}
GestureCard(R.string.gesture_pinch, config.pinchEnabled, { change(config.copy(pinchEnabled = it)) }) {
GestureActionField("pinch", R.string.gesture_pinch, config.pinchAction, ZOOM_ACTIONS) { change(config.copy(pinchAction = it)) }
}
}
2 -> {
GestureSection(R.string.gesture_tab_three, R.string.gesture_three_hint)
GestureCard(R.string.gesture_three_tap, config.threeFingerTapEnabled, { change(config.copy(threeFingerTapEnabled = it)) }) {
GestureActionField("three-tap", R.string.gesture_three_tap, config.threeFingerTapAction, actions) { change(config.copy(threeFingerTapAction = it)) }
}
GestureCard(R.string.gesture_three_drag, config.threeFingerDragEnabled, { change(config.copy(threeFingerDragEnabled = it)) }) {
GestureActionField("three-drag", R.string.gesture_three_drag, config.threeFingerDragAction, PAN_ACTIONS) { change(config.copy(threeFingerDragAction = it)) }
}
GestureCard(R.string.gesture_three_hold, config.threeFingerHoldEnabled, { change(config.copy(threeFingerHoldEnabled = it)) }) {
GestureActionField("three-hold", R.string.gesture_three_hold, config.threeFingerHoldAction, actions) { change(config.copy(threeFingerHoldAction = it)) }
GestureHoldOptions(config.threeFingerHoldAction, config.threeFingerHoldMouseBehavior, config.threeFingerHoldDelay, { change(config.copy(threeFingerHoldMouseBehavior = it)) }, { change(config.copy(threeFingerHoldDelay = it)) })
}
}
3 -> {
GestureSection(R.string.gesture_tab_options, R.string.gesture_options_hint)
GestureSettingsCard(R.string.gesture_movement) {
Text(stringResource(R.string.gesture_movement_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
GestureChoiceChips(config.mouseDragMovementMode, listOf(MOUSE_DRAG_MOVEMENT_DIRECT to stringResource(R.string.gesture_direct), MOUSE_DRAG_MOVEMENT_RELATIVE to stringResource(R.string.gesture_relative))) { change(config.copy(mouseDragMovementMode = it)) }
Text(stringResource(R.string.gesture_middle_relative_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
}
GestureSettingsCard(R.string.gesture_recognition) {
Text(stringResource(R.string.gesture_threshold, config.gestureThreshold), style = MaterialTheme.typography.bodyMedium)
Slider(config.gestureThreshold.toFloat(), { change(config.copy(gestureThreshold = it.toInt())) }, valueRange = 10f..100f, steps = 17)
Text(stringResource(R.string.gesture_threshold_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
}
GestureSettingsCard(R.string.gesture_feedback) {
GestureToggle(R.string.gesture_debug, config.showGestureDebugOverlay) { change(config.copy(showGestureDebugOverlay = it)) }
}
OutlinedButton(onClick = { change(TouchGestureConfig()) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.gesture_reset)) }
}
}
}
}
}
}
@Composable
private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () -> Unit,
footer: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
val p = LocalPalette.current
// The app window does not opt into RTL, so these dialogs follow their resource locale explicitly.
val direction = if (LocalConfiguration.current.layoutDirection == android.view.View.LAYOUT_DIRECTION_RTL) LayoutDirection.Rtl else LayoutDirection.Ltr
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
CompositionLocalProvider(LocalLayoutDirection provides direction) {
Box(Modifier.fillMaxSize().systemBarsPadding().imePadding().padding(12.dp), contentAlignment = Alignment.Center) {
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)) }
}
content()
HorizontalDivider(color = p.line)
footer()
}
}
}
}
}
}
@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)
}
}
@Composable
private fun GestureCard(@StringRes title: Int, checked: Boolean, onToggle: (Boolean) -> Unit,
content: @Composable ColumnScope.() -> Unit) {
val p = LocalPalette.current
Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = p.surface,
border = BorderStroke(1.dp, if (checked) p.line2 else p.line)) {
Column {
Row(Modifier.fillMaxWidth().toggleable(checked, role = Role.Switch, onValueChange = onToggle)
.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f).padding(end = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
}
Switch(checked, onCheckedChange = null)
}
if (checked) Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content)
}
}
}
@Composable
private fun GestureActionField(id: String, @StringRes gesture: Int, selected: String, choices: List<String>, onChoose: (String) -> Unit) {
var open by remember { mutableStateOf(false) }
val p = LocalPalette.current
Surface(onClick = { open = true }, modifier = Modifier.fillMaxWidth().testTag("gesture-choice-$id"), shape = RoundedCornerShape(10.dp),
color = p.surfaceVariant, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) {
Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(stringResource(R.string.gesture_action_label), color = p.onSurfaceVariant, style = MaterialTheme.typography.labelSmall)
Text(gestureActionTitle(selected), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
}
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) }
}
@Composable
internal fun GestureActionPicker(choices: List<String>, selected: String, gesture: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
var query by rememberSaveable { mutableStateOf("") }
var category by remember { mutableStateOf<GestureActionGroup?>(null) }
val catalog = choices.distinct().map { GestureAction(it, gestureActionTitle(it), gestureActionGroup(it)) }
val groups = GestureActionGroup.entries.filter { group -> catalog.any { it.group == group } }
val shown = filterGestureActions(catalog, query, category)
val p = LocalPalette.current
GestureDialogFrame(stringResource(R.string.gesture_action), stringResource(R.string.gesture_action_for, gesture), onDismiss) {
OutlinedTextField(query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).testTag("gesture-action-search"),
singleLine = true, shape = RoundedCornerShape(12.dp), placeholder = { Text(stringResource(R.string.gesture_search_actions)) },
leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = {
if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { Icon(Icons.Default.Close, stringResource(R.string.gesture_clear_search)) }
})
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GestureFilterChip(category == null, onClick = { category = null }, label = { Text(stringResource(R.string.gesture_all_actions)) })
if (groups.size > 1) groups.forEach { group ->
GestureFilterChip(category == group, onClick = { category = group }, label = { Text(stringResource(group.title)) }, modifier = Modifier.testTag("gesture-category-${group.name}"))
}
}
if (shown.isEmpty()) Box(Modifier.weight(1f).fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.gesture_no_actions), color = p.onSurfaceVariant)
} else LazyColumn(Modifier.weight(1f).fillMaxWidth().testTag("gesture-action-list"), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)) {
groups.forEach { group ->
val entries = shown.filter { it.group == group }
if (entries.isNotEmpty()) {
item(key = group.name) { Text(stringResource(group.title), style = MaterialTheme.typography.labelLarge, color = p.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp, bottom = 5.dp)) }
items(entries, key = { it.id }) { action ->
val isSelected = action.id == selected
Surface(shape = RoundedCornerShape(12.dp), color = if (isSelected) p.primary.copy(alpha = 0.12f) else p.surface,
border = BorderStroke(1.dp, if (isSelected) p.primary else p.line)) {
Row(Modifier.fillMaxWidth().selectable(isSelected, role = Role.RadioButton, onClick = { onChoose(action.id) })
.testTag("gesture-action-${action.id}").padding(horizontal = 14.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(when (group) { GestureActionGroup.MOUSE -> Icons.Default.Mouse; GestureActionGroup.APP -> Icons.Default.TouchApp; else -> Icons.Default.Keyboard },
null, tint = if (isSelected) p.primary else p.onSurfaceVariant, modifier = Modifier.size(22.dp))
Text(action.title, Modifier.weight(1f).padding(horizontal = 14.dp), style = MaterialTheme.typography.bodyLarge)
RadioButton(isSelected, onClick = null)
}
}
}
}
}
}
}
}
@Composable
private fun GestureHoldOptions(action: String, behavior: String, delay: Int, onBehavior: (String) -> Unit, onDelay: (Int) -> Unit) {
if (TouchGestureConfig.isMouseButtonAction(action)) {
Text(stringResource(R.string.gesture_mouse_behavior), style = MaterialTheme.typography.labelMedium, color = LocalPalette.current.onSurfaceVariant)
GestureChoiceChips(behavior, listOf(MOUSE_BEHAVIOR_HOLD to stringResource(R.string.gesture_hold_button), MOUSE_BEHAVIOR_CLICK to stringResource(R.string.gesture_click_once)), onBehavior)
}
GestureDelay(stringResource(R.string.gesture_hold_delay), delay, onDelay)
}
@Composable
private fun GestureFilterChip(selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit, modifier: Modifier = Modifier) {
val p = LocalPalette.current
FilterChip(selected, onClick, label, modifier = modifier, colors = FilterChipDefaults.filterChipColors(
containerColor = p.surface, labelColor = p.onSurfaceVariant,
selectedContainerColor = p.primary.copy(alpha = 0.16f), selectedLabelColor = p.onBackground))
}
@Composable
private fun GestureChoiceChips(selected: String, options: List<Pair<String, String>>, choose: (String) -> Unit) {
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { (id, label) -> GestureFilterChip(selected == id, onClick = { choose(id) }, label = { Text(label) }) }
}
}
@Composable
private fun GestureSettingsCard(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) {
val p = LocalPalette.current
Surface(shape = RoundedCornerShape(16.dp), color = p.surface, border = BorderStroke(1.dp, p.line)) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
content()
}
}
}
@Composable
private fun GestureToggle(@StringRes title: Int, checked: Boolean, change: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth().toggleable(checked, role = Role.Switch, onValueChange = change).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(title), Modifier.weight(1f).padding(end = 12.dp), style = MaterialTheme.typography.bodyMedium)
Switch(checked, null)
}
}
@Composable
private fun GestureDelay(label: String, value: Int, change: (Int) -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.gesture_ms, value), Modifier.background(LocalPalette.current.surfaceVariant, RoundedCornerShape(6.dp)).padding(horizontal = 10.dp, vertical = 5.dp), style = MaterialTheme.typography.labelLarge)
}
Slider(value.toFloat().coerceIn(100f, 1000f), { change((it / 50).toInt() * 50) }, valueRange = 100f..1000f, steps = 17)
}
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">الإيماءات</string>
<string name="gesture_settings">إعدادات إيماءات اللمس</string>
<string name="gesture_settings_hint">خصّص إيماءات الفأرة ولوحة المفاتيح. اختر الإيماءات كوضع للمس لاستخدامها.</string>
<string name="gesture_tap">نقرة</string>
<string name="gesture_drag">سحب بإصبع واحد</string>
<string name="gesture_long_press">ضغط مطوّل</string>
<string name="gesture_double_tap">نقر مزدوج (نقرتان بالزر الأيسر)</string>
<string name="gesture_two_tap">نقر بإصبعين</string>
<string name="gesture_two_drag">سحب بإصبعين</string>
<string name="gesture_two_hold">ضغط مستمر بإصبعين</string>
<string name="gesture_three_tap">نقر بثلاثة أصابع</string>
<string name="gesture_three_drag">سحب بثلاثة أصابع</string>
<string name="gesture_three_hold">ضغط مستمر بثلاثة أصابع</string>
<string name="gesture_pinch">ضمّ الإصبعين أو إبعادهما للتكبير والتصغير</string>
<string name="gesture_hold_button">إبقاء الزر مضغوطًا</string>
<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>
<string name="gesture_left_click">نقر بالزر الأيسر</string>
<string name="gesture_right_click">نقر بالزر الأيمن</string>
<string name="gesture_middle_click">نقر بالزر الأوسط</string>
<string name="gesture_keyboard">إظهار لوحة المفاتيح</string>
<string name="gesture_left_drag">نقر أيسر وسحب</string>
<string name="gesture_right_drag">نقر أيمن وسحب</string>
<string name="gesture_middle_pan">تحريك العرض بزر الفأرة الأوسط</string>
<string name="gesture_middle_pan_inverted">تحريك العرض بزر الفأرة الأوسط (معكوس)</string>
<string name="gesture_arrows">مفاتيح الأسهم</string>
<string name="gesture_arrows_inverted">مفاتيح الأسهم (معكوسة)</string>
<string name="gesture_wasd_inverted">WASD (معكوس)</string>
<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>
<string name="gesture_no_actions">لا توجد إجراءات مطابقة. جرّب اسم مفتاح أو إجراء آخر.</string>
<string name="gesture_group_mouse">الفأرة</string>
<string name="gesture_group_app">إجراءات التطبيق</string>
<string name="gesture_group_navigation">التنقل</string>
<string name="gesture_group_letters">الأحرف</string>
<string name="gesture_group_numbers">الأرقام</string>
<string name="gesture_group_function">مفاتيح الوظائف</string>
<string name="gesture_group_modifiers">مفاتيح التعديل</string>
<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>
<string name="gesture_key_numpad">لوحة الأرقام %1$s</string>
<string name="gesture_key_escape">هروب</string>
<string name="gesture_key_backspace">مسافة للخلف</string>
<string name="gesture_key_enter">إدخال</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">مسافة</string>
<string name="gesture_key_delete">حذف</string>
<string name="gesture_key_insert">إدراج</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">صفحة لأعلى</string>
<string name="gesture_key_page_down">صفحة لأسفل</string>
<string name="gesture_key_print_screen">طباعة الشاشة</string>
<string name="gesture_key_up">سهم لأعلى</string>
<string name="gesture_key_down">سهم لأسفل</string>
<string name="gesture_key_left_arrow">سهم لليسار</string>
<string name="gesture_key_right_arrow">سهم لليمين</string>
<string name="gesture_key_caps_lock">قفل الأحرف الكبيرة</string>
<string name="gesture_key_num_lock">قفل الأرقام</string>
<string name="gesture_key_scroll_lock">قفل التمرير</string>
<string name="gesture_key_left">%1$s الأيسر</string>
<string name="gesture_key_right">%1$s الأيمن</string>
<string name="gesture_key_shift">Shift</string>
</resources>
+50 -4
View File
@@ -546,7 +546,7 @@
<string name="perf_gpuclock_hint">لـ Adreno فقط. تقطّع أقل، مع استهلاك أكبر للبطارية والحرارة.</string>
<string name="perf_fixes">إصلاحات الجلسة</string>
<string name="perf_sysmem">Turnip: التصيير عبر sysmem</string>
<string name="perf_sysmem_hint">أسرع لـ Steam ولمعظم الألعاب. مطلوب على Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">أسرع لعميل Steam؛ في الألعاب يختار برنامج التشغيل بين العرض المقسّم إلى مربعات والعرض في ذاكرة النظام لكل مرحلة. مطلوب على Adreno 710/720/722.</string>
<string name="perf_xalia">تجاوز مساعد xalia في Steam</string>
<string name="perf_fastsync">droiddeck-ntsync (تجريبي)</string>
<string name="perf_fastsync_hint">بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux، للاختبار. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية.</string>
@@ -2008,6 +2008,8 @@
<string name="stores_title">المتاجر</string>
<string name="stores_settings">إعدادات المتاجر</string>
<string name="stores_settings_eyebrow">الإعدادات</string>
<string name="stores_tabs_arrange">ترتيب التبويبات</string>
<string name="stores_tabs_arrange_done">تم</string>
<string name="stores_chip_signed_out">(لم يتم تسجيل الدخول)</string>
<string name="stores_tab_store">المتجر</string>
<string name="stores_tab_library">المكتبة</string>
@@ -2234,7 +2236,7 @@
<string name="logo_pos_size">الحجم</string>
<string name="logo_pos_save">حفظ</string>
<string name="logo_pos_reset">العودة إلى الافتراضي</string>
<string name="logo_pos_hint">يُرسم فوق خلفية اللعبة كما يفعل Steam. قد يختلف الموضع الدقيق ببضع بكسلات.</string>
<string name="logo_pos_hint">قد يختلف هذا الموضع بضع نقاط بكسل.</string>
<string name="logo_pos_no_logo">لا توجد لهذه اللعبة شعار بعد. اختر واحدًا من الشعار لتراه هنا.</string>
<string name="logo_pos_failed">تعذّر حفظ موضع الشعار. حاول مرة أخرى.</string>
<string name="art_background">الخلفية</string>
@@ -2815,7 +2817,7 @@
<string name="boot_movie">فيديو الإقلاع</string>
<string name="boot_movie_video">الفيديو</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d إطار/ث</string>
<string name="boot_movie_note">معدل 60 إطارًا/ث أكثر سلاسة لكنه يستهلك المعالج أكثر أثناء بدء Steam. فيديو البدء المختار في إعدادات Steam نفسها له الأولوية.</string>
<string name="boot_movie_note">معدل 60 إطارًا/ث أكثر سلاسة لكنه يستهلك المعالج أكثر أثناء بدء Steam. فيديو مختار في إعدادات Steam نفسها يظهر هنا أيضًا.</string>
<string name="boot_movie_sound">الصوت</string>
<string name="boot_movie_custom">فيديو خاص بك</string>
<string name="boot_movie_custom_hint">MP4 أو MOV أو MKV أو WebM. تُحوَّل الصيغ الأخرى والملفات الأكبر من %1$d ميغابايت إلى WebM بدقة 720p على الجهاز، حتى %2$d ثانية.</string>
@@ -2829,6 +2831,51 @@
<string name="boot_movie_converting_started">جارٍ تحويل %1$s لـ Steam. أبقِ DroidDeck مفتوحًا.</string>
<string name="boot_movie_unsupported">لا يستطيع هذا الجهاز فك ترميز هذا الفيديو.</string>
<string name="boot_movie_import_failed">تعذّر استيراد الفيديو</string>
<string name="boot_movie_remove_confirm">إزالة %1$s؟</string>
<string name="boot_movie_remove_confirm_body">سيُحذف الفيديو من DroidDeck ومن مجلد فيديوهات Steam.</string>
<string name="boot_movie_tab_installed">المثبّتة</string>
<string name="boot_movie_tab_import">استيراد</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">الصوت</string>
<string name="boot_movie_installed">الفيديوهات المثبّتة</string>
<string name="boot_movie_browse">استعراض</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">مستورد</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">متجر النقاط</string>
<string name="boot_movie_report">تقرير فيديو الإقلاع</string>
<string name="boot_movie_report_hint">يحفظ ما يعرفه DroidDeck وSteam عن فيديوهات الإقلاع في ملف نصي يمكنك إرفاقه ببلاغ خطأ.</string>
<string name="boot_movie_report_save">حفظ</string>
<string name="boot_movie_report_saved">تم الحفظ في %1$s</string>
<string name="boot_movie_report_failed">تعذّر حفظ التقرير.</string>
<string name="boot_movie_volume">مستوى الصوت</string>
<string name="boot_movie_length">المدة</string>
<string name="boot_movie_seconds">%1$d ث</string>
<string name="boot_movie_length_hint">عدد الثواني التي يتم تشغيلها من الفيديو. أي جزء أطول من ذلك يُقطع في النهاية.</string>
<string name="boot_movie_repo_search">البحث في SteamDeckRepo</string>
<string name="boot_movie_repo_all">كل الأجهزة</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">الرائجة</string>
<string name="boot_movie_repo_downloads">الأكثر تنزيلًا</string>
<string name="boot_movie_repo_likes">الأكثر إعجابًا</string>
<string name="boot_movie_repo_newest">الأحدث</string>
<string name="boot_movie_repo_failed">تعذّر الوصول إلى SteamDeckRepo</string>
<string name="boot_movie_repo_retry">إعادة المحاولة</string>
<string name="boot_movie_repo_empty">لم يُعثر على فيديوهات</string>
<string name="boot_movie_repo_install">تثبيت</string>
<string name="boot_movie_repo_use">استخدام</string>
<string name="boot_movie_in_use">قيد الاستخدام</string>
<string name="boot_movie_repo_installed">تم تثبيت %1$s</string>
<string name="boot_movie_repo_download_failed">تعذّر تنزيل هذا الفيديو</string>
<string name="boot_movie_repo_previous">السابق</string>
<string name="boot_movie_repo_next">التالي</string>
<string name="boot_movie_repo_page">الصفحة %1$d</string>
<string name="boot_movie_repo_clear">مسح</string>
<string name="boot_movie_repo_go_to_page">الانتقال إلى صفحة</string>
<string name="boot_movie_repo_preview">معاينة</string>
<string name="boot_movie_repo_preview_failed">تعذر تشغيل المعاينة</string>
<string name="glasses_experimental">كاميرات Luma Ultra - تجريبية</string>
<string name="glasses_joycons">تتبع موضع Joy-Con</string>
<string name="glasses_joycons_hint">استخدم كاميرات النظارات الذكية لتحديد موضع وحدات تحكم Joy-Con المقترنة. يتم التحكم في الدوران والأزرار من خلال وحدات تحكم Joy-Con. يتم تقدير الموضع عندما تكون معصميك مخفية. يحل هذا محل وحدات التحكم التي يتم تشغيلها باليدين ويوقف تتبع كاميرا الهاتف مؤقتًا. وجّه وحدات تحكم Joy-Con للأمام عند توصيل مركز التتبع أو إعادة ضبطه.</string>
@@ -3002,7 +3049,6 @@
<string name="spatial_hands_wait_stable">جارٍ انتظار استقرار تتبع الرأس؛ أعد ضبط مركز التتبع إذا طُلب منك ذلك.</string>
<string name="spatial_old_frame">المراقبة بالكاميرات قديمة جدًا؛ في انتظار زوج جديد</string>
<string name="spatial_fit_ready">تناسب %1$.2f بكسل. قم بقياس المسافة التي حددتها من الكاميرا اليسرى إلى مركز هدف المعايرة، ثم قم بالتحقق من صحتها.</string>
<string name="spatial_opencv_error">OpenCV غير متاح: %1$s</string>
<string name="spatial_restored">تمت استعادة %1$d من المشاهدات المحفوظة. قم بحساب المعايرة لإعادة المحاولة، أو ابدأ مجموعة جديدة لالتقاط المزيد.</string>
<string name="spatial_captured">ملتقطة %1$d / 24 مشاهدة. حافظ على هدف المعايرة ثابتًا؛ حرك النظارات الذكية للعرض التالي.</string>
<string name="spatial_capture_corners">رُفض الالتقاط: تظهر %1$d / 12 زاوية مطلوبة في كلتا الكاميرتين. أظهر جزءًا أكبر من لوحة المعايرة، وابقَ ثابتًا ثم حاول مرة أخرى.</string>
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Gestos</string>
<string name="gesture_settings">Ajustes de gestos táctiles</string>
<string name="gesture_settings_hint">Personaliza los gestos de ratón y teclado. Selecciona Gestos como modo táctil para usarlos.</string>
<string name="gesture_tap">Toque</string>
<string name="gesture_drag">Arrastre con un dedo</string>
<string name="gesture_long_press">Pulsación prolongada</string>
<string name="gesture_double_tap">Doble toque (doble clic izquierdo)</string>
<string name="gesture_two_tap">Toque con dos dedos</string>
<string name="gesture_two_drag">Arrastre con dos dedos</string>
<string name="gesture_two_hold">Pulsación sostenida con dos dedos</string>
<string name="gesture_three_tap">Toque con tres dedos</string>
<string name="gesture_three_drag">Arrastre con tres dedos</string>
<string name="gesture_three_hold">Pulsación sostenida con tres dedos</string>
<string name="gesture_pinch">Pellizcar para ampliar</string>
<string name="gesture_hold_button">Mantener el botón pulsado</string>
<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>
<string name="gesture_left_click">Clic izquierdo</string>
<string name="gesture_right_click">Clic derecho</string>
<string name="gesture_middle_click">Clic central</string>
<string name="gesture_keyboard">Mostrar teclado</string>
<string name="gesture_left_drag">Clic izquierdo y arrastre</string>
<string name="gesture_right_drag">Clic derecho y arrastre</string>
<string name="gesture_middle_pan">Desplazamiento con el botón central</string>
<string name="gesture_middle_pan_inverted">Desplazamiento con el botón central (invertido)</string>
<string name="gesture_arrows">Teclas de dirección</string>
<string name="gesture_arrows_inverted">Teclas de dirección (invertidas)</string>
<string name="gesture_wasd_inverted">WASD (invertido)</string>
<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>
<string name="gesture_no_actions">No hay acciones coincidentes. Prueba con otro nombre de tecla o acción.</string>
<string name="gesture_group_mouse">Ratón</string>
<string name="gesture_group_app">Acciones de la aplicación</string>
<string name="gesture_group_navigation">Navegación</string>
<string name="gesture_group_letters">Letras</string>
<string name="gesture_group_numbers">Números</string>
<string name="gesture_group_function">Teclas de función</string>
<string name="gesture_group_modifiers">Teclas modificadoras</string>
<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>
<string name="gesture_key_numpad">Teclado numérico %1$s</string>
<string name="gesture_key_escape">Escape</string>
<string name="gesture_key_backspace">Retroceso</string>
<string name="gesture_key_enter">Intro</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Espacio</string>
<string name="gesture_key_delete">Supr</string>
<string name="gesture_key_insert">Insertar</string>
<string name="gesture_key_home">Inicio</string>
<string name="gesture_key_end">Fin</string>
<string name="gesture_key_page_up">Re Pág</string>
<string name="gesture_key_page_down">Av Pág</string>
<string name="gesture_key_print_screen">Impr Pant</string>
<string name="gesture_key_up">Flecha arriba</string>
<string name="gesture_key_down">Flecha abajo</string>
<string name="gesture_key_left_arrow">Flecha izquierda</string>
<string name="gesture_key_right_arrow">Flecha derecha</string>
<string name="gesture_key_caps_lock">Bloq Mayús</string>
<string name="gesture_key_num_lock">Bloq Num</string>
<string name="gesture_key_scroll_lock">Bloq Despl</string>
<string name="gesture_key_left">%1$s izquierdo</string>
<string name="gesture_key_right">%1$s derecho</string>
<string name="gesture_key_shift">Mayús</string>
</resources>
+50 -4
View File
@@ -533,7 +533,7 @@
<string name="perf_gpuclock_hint">Solo Adreno. Menos microcortes, más consumo de batería y calor.</string>
<string name="perf_fixes">Correcciones de sesión</string>
<string name="perf_sysmem">Turnip: renderizado sysmem</string>
<string name="perf_sysmem_hint">Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722.</string>
<string name="perf_xalia">Omitir el asistente xalia de Steam</string>
<string name="perf_fastsync">droiddeck-ntsync (experimental)</string>
<string name="perf_fastsync_hint">Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.</string>
@@ -1975,6 +1975,8 @@
<string name="stores_title">Tiendas</string>
<string name="stores_settings">Ajustes de las tiendas</string>
<string name="stores_settings_eyebrow">Ajustes</string>
<string name="stores_tabs_arrange">Organizar pestañas</string>
<string name="stores_tabs_arrange_done">Listo</string>
<string name="stores_chip_signed_out">(sin iniciar sesión)</string>
<string name="stores_tab_store">Tienda</string>
<string name="stores_tab_library">Biblioteca</string>
@@ -2185,7 +2187,7 @@
<string name="logo_pos_size">Tamaño</string>
<string name="logo_pos_save">Guardar</string>
<string name="logo_pos_reset">Volver al valor predeterminado</string>
<string name="logo_pos_hint">Se dibuja sobre el fondo del juego, igual que en Steam. La posición exacta puede variar unos píxeles.</string>
<string name="logo_pos_hint">Este punto exacto puede variar unos pocos píxeles.</string>
<string name="logo_pos_no_logo">Este juego aún no tiene logotipo. Elige uno en Logotipo para verlo aquí.</string>
<string name="logo_pos_failed">No se pudo guardar la posición del logotipo. Inténtalo de nuevo.</string>
<string name="art_background">Fondo</string>
@@ -2655,7 +2657,7 @@
<string name="boot_movie">Video de arranque</string>
<string name="boot_movie_video">Video</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un video de inicio elegido en la configuración de Steam tiene prioridad.</string>
<string name="boot_movie_note">60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un video elegido en la configuración de Steam también aparece aquí.</string>
<string name="boot_movie_sound">Sonido</string>
<string name="boot_movie_custom">Tu propio video</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV o WebM. Otros formatos y archivos de más de %1$d MB se convierten a WebM 720p en el dispositivo, hasta %2$d segundos.</string>
@@ -2669,6 +2671,51 @@
<string name="boot_movie_converting_started">Convirtiendo %1$s para Steam. Mantén DroidDeck abierto.</string>
<string name="boot_movie_unsupported">Este dispositivo no puede decodificar ese video.</string>
<string name="boot_movie_import_failed">No se pudo importar ese video</string>
<string name="boot_movie_remove_confirm">¿Quitar %1$s?</string>
<string name="boot_movie_remove_confirm_body">Esto elimina el video de DroidDeck y de la carpeta de videos de Steam.</string>
<string name="boot_movie_tab_installed">Instalados</string>
<string name="boot_movie_tab_import">Importar</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Sonido</string>
<string name="boot_movie_installed">Videos instalados</string>
<string name="boot_movie_browse">Explorar</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Importado</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Tienda de puntos</string>
<string name="boot_movie_report">Informe de películas de arranque</string>
<string name="boot_movie_report_hint">Guarda en un archivo de texto lo que DroidDeck y Steam saben de las películas de arranque, para adjuntarlo a un informe de error.</string>
<string name="boot_movie_report_save">Guardar</string>
<string name="boot_movie_report_saved">Guardado en %1$s</string>
<string name="boot_movie_report_failed">No se pudo guardar el informe.</string>
<string name="boot_movie_volume">Volumen</string>
<string name="boot_movie_length">Duración</string>
<string name="boot_movie_seconds">%1$d s</string>
<string name="boot_movie_length_hint">Cuántos segundos del video se reproducen. Todo lo que dure más se corta al final.</string>
<string name="boot_movie_repo_search">Buscar en SteamDeckRepo</string>
<string name="boot_movie_repo_all">Todos los dispositivos</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Tendencias</string>
<string name="boot_movie_repo_downloads">Más descargados</string>
<string name="boot_movie_repo_likes">Más gustados</string>
<string name="boot_movie_repo_newest">Más recientes</string>
<string name="boot_movie_repo_failed">No se pudo conectar con SteamDeckRepo</string>
<string name="boot_movie_repo_retry">Reintentar</string>
<string name="boot_movie_repo_empty">No se encontraron videos</string>
<string name="boot_movie_repo_install">Instalar</string>
<string name="boot_movie_repo_use">Usar</string>
<string name="boot_movie_in_use">En uso</string>
<string name="boot_movie_repo_installed">%1$s instalado</string>
<string name="boot_movie_repo_download_failed">No se pudo descargar ese video</string>
<string name="boot_movie_repo_previous">Anterior</string>
<string name="boot_movie_repo_next">Siguiente</string>
<string name="boot_movie_repo_page">Página %1$d</string>
<string name="boot_movie_repo_clear">Borrar</string>
<string name="boot_movie_repo_go_to_page">Ir a la página</string>
<string name="boot_movie_repo_preview">Vista previa</string>
<string name="boot_movie_repo_preview_failed">No se pudo reproducir la vista previa</string>
<string name="stores_itch_key">Clave de API</string>
<string name="stores_itch_get_key">Obtener una clave</string>
<string name="stores_itch_key_rejected">Clave rechazada</string>
@@ -2852,7 +2899,6 @@
<string name="spatial_hands_wait_stable">Esperando un seguimiento de cabeza estable; restablece el centro de seguimiento si se solicita.</string>
<string name="spatial_old_frame">La observación con cámara es demasiado antigua; esperando un par nuevo</string>
<string name="spatial_fit_ready">Ajustar %1$.2f px. Mida la distancia seleccionada desde la cámara izquierda hasta el centro del objetivo de calibración y luego valide.</string>
<string name="spatial_opencv_error">OpenCV no disponible: %1$s</string>
<string name="spatial_restored">%1$d vistas guardadas restauradas. Calcule la calibración para volver a intentarlo o comience un nuevo conjunto para capturar más.</string>
<string name="spatial_captured">Capturado %1$d / 24 vistas. Mantenga fijo el objetivo de calibración; Mueva las gafas inteligentes para la siguiente vista.</string>
<string name="spatial_capture_corners">Captura rechazada: %1$d / 12 esquinas requeridas visibles en ambas cámaras. Muestre más del objetivo de calibración, quédese quieto y vuelva a intentarlo.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Жестове</string>
<string name="gesture_settings">Настройки на сензорните жестове</string>
<string name="gesture_settings_hint">Настройте жестовете за мишка и клавиатура. За да ги използвате, изберете сензорен режим „Жестове“.</string>
<string name="gesture_tap">Докосване</string>
<string name="gesture_drag">Плъзгане с един пръст</string>
<string name="gesture_long_press">Продължително натискане</string>
<string name="gesture_double_tap">Двойно докосване (двойно ляво щракване)</string>
<string name="gesture_two_tap">Докосване с два пръста</string>
<string name="gesture_two_drag">Плъзгане с два пръста</string>
<string name="gesture_two_hold">Задържане с два пръста</string>
<string name="gesture_three_tap">Докосване с три пръста</string>
<string name="gesture_three_drag">Плъзгане с три пръста</string>
<string name="gesture_three_hold">Задържане с три пръста</string>
<string name="gesture_pinch">Мащабиране със събиране или раздалечаване на пръстите</string>
<string name="gesture_hold_button">Задържане на бутона натиснат</string>
<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>
<string name="gesture_left_click">Ляво щракване</string>
<string name="gesture_right_click">Дясно щракване</string>
<string name="gesture_middle_click">Средно щракване</string>
<string name="gesture_keyboard">Показване на клавиатурата</string>
<string name="gesture_left_drag">Ляво щракване и плъзгане</string>
<string name="gesture_right_drag">Дясно щракване и плъзгане</string>
<string name="gesture_middle_pan">Преместване със средния бутон</string>
<string name="gesture_middle_pan_inverted">Преместване със средния бутон (обърнато)</string>
<string name="gesture_arrows">Клавиши със стрелки</string>
<string name="gesture_arrows_inverted">Клавиши със стрелки (обърнати)</string>
<string name="gesture_wasd_inverted">WASD (обърнато)</string>
<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>
<string name="gesture_no_actions">Няма съвпадащи действия. Опитайте друго име на клавиш или действие.</string>
<string name="gesture_group_mouse">Мишка</string>
<string name="gesture_group_app">Действия на приложението</string>
<string name="gesture_group_navigation">Навигация</string>
<string name="gesture_group_letters">Букви</string>
<string name="gesture_group_numbers">Цифри</string>
<string name="gesture_group_function">Функционални клавиши</string>
<string name="gesture_group_modifiers">Модифициращи клавиши</string>
<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>
<string name="gesture_key_numpad">Цифрова клавиатура %1$s</string>
<string name="gesture_key_escape">Esc</string>
<string name="gesture_key_backspace">Backspace</string>
<string name="gesture_key_enter">Enter</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">Delete</string>
<string name="gesture_key_insert">Insert</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">Page Up</string>
<string name="gesture_key_page_down">Page Down</string>
<string name="gesture_key_print_screen">Print Screen</string>
<string name="gesture_key_up">Стрелка нагоре</string>
<string name="gesture_key_down">Стрелка надолу</string>
<string name="gesture_key_left_arrow">Стрелка наляво</string>
<string name="gesture_key_right_arrow">Стрелка надясно</string>
<string name="gesture_key_caps_lock">Caps Lock</string>
<string name="gesture_key_num_lock">Num Lock</string>
<string name="gesture_key_scroll_lock">Scroll Lock</string>
<string name="gesture_key_left">Ляв %1$s</string>
<string name="gesture_key_right">Десен %1$s</string>
<string name="gesture_key_shift">Shift</string>
</resources>
+50 -4
View File
@@ -538,7 +538,7 @@
<string name="perf_gpuclock_hint">Само за Adreno. По-малко засечки, по-голямо използване на батерията и повече топлина.</string>
<string name="perf_fixes">Корекции на сесията</string>
<string name="perf_sysmem">Turnip: рендиране sysmem</string>
<string name="perf_sysmem_hint">По-бързо за Steam и повечето игри. Задължително на Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">По-бързо за клиента на Steam; в игрите драйверът избира за всеки проход между рендериране на плочки и в системната памет. Задължително на Adreno 710/720/722.</string>
<string name="perf_xalia">Пропускане на помощника xalia на Steam</string>
<string name="perf_fastsync">droiddeck-ntsync (експериментално)</string>
<string name="perf_fastsync_hint">Заместител на потребителско ниво за драйвера ntsync на Linux, за тестване. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия.</string>
@@ -1924,6 +1924,8 @@
<string name="stores_title">Магазини</string>
<string name="stores_settings">Настройки на магазините</string>
<string name="stores_settings_eyebrow">Настройки</string>
<string name="stores_tabs_arrange">Подреждане на раздели</string>
<string name="stores_tabs_arrange_done">Готово</string>
<string name="stores_chip_signed_out">(не е вписан)</string>
<string name="stores_tab_store">Магазин</string>
<string name="stores_tab_library">Библиотека</string>
@@ -2150,7 +2152,7 @@
<string name="logo_pos_size">Размер</string>
<string name="logo_pos_save">Запази</string>
<string name="logo_pos_reset">Обратно по подразбиране</string>
<string name="logo_pos_hint">Рисува се върху фона на играта, както прави Steam. Точната позиция може да се различава с няколко пиксела.</string>
<string name="logo_pos_hint">Точното място може да се различава с няколко пиксела.</string>
<string name="logo_pos_no_logo">Тази игра още няма лого. Избери такова в Лого, за да го видиш тук.</string>
<string name="logo_pos_failed">Позицията на логото не можа да се запази. Опитай отново.</string>
<string name="art_background">Фон</string>
@@ -2679,7 +2681,7 @@
<string name="boot_movie">Начално видео</string>
<string name="boot_movie_video">Видео</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d кадъра/с</string>
<string name="boot_movie_note">60 кадъра/с изглежда по-гладко, но натоварва повече процесора, докато Steam се стартира. Начално видео, избрано в настройките на Steam, има предимство.</string>
<string name="boot_movie_note">60 кадъра/с изглежда по-гладко, но натоварва повече процесора, докато Steam се стартира. Видео, избрано в настройките на Steam, също се показва тук.</string>
<string name="boot_movie_sound">Звук</string>
<string name="boot_movie_custom">Собствено видео</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV или WebM. Други формати и файлове над %1$d MB се конвертират на устройството до WebM 720p, до %2$d секунди.</string>
@@ -2693,6 +2695,51 @@
<string name="boot_movie_converting_started">%1$s се конвертира за Steam. Не затваряйте DroidDeck.</string>
<string name="boot_movie_unsupported">Устройството не може да декодира това видео.</string>
<string name="boot_movie_import_failed">Видеото не можа да бъде импортирано</string>
<string name="boot_movie_remove_confirm">Да се премахне ли %1$s?</string>
<string name="boot_movie_remove_confirm_body">Това изтрива видеото от DroidDeck и от папката с видеа на Steam.</string>
<string name="boot_movie_tab_installed">Инсталирани</string>
<string name="boot_movie_tab_import">Импортиране</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Звук</string>
<string name="boot_movie_installed">Инсталирани видеа</string>
<string name="boot_movie_browse">Преглед</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Импортирано</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Магазин за точки</string>
<string name="boot_movie_report">Отчет за стартовите филми</string>
<string name="boot_movie_report_hint">Записва в текстов файл какво знаят DroidDeck и Steam за стартовите филми, за да го прикачите към доклад за грешка.</string>
<string name="boot_movie_report_save">Запази</string>
<string name="boot_movie_report_saved">Записано в %1$s</string>
<string name="boot_movie_report_failed">Отчетът не можа да бъде записан.</string>
<string name="boot_movie_volume">Сила на звука</string>
<string name="boot_movie_length">Продължителност</string>
<string name="boot_movie_seconds">%1$d с</string>
<string name="boot_movie_length_hint">Колко секунди от филма се възпроизвеждат. Всичко по-дълго се отрязва в края.</string>
<string name="boot_movie_repo_search">Търсене в SteamDeckRepo</string>
<string name="boot_movie_repo_all">Всички устройства</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Популярни</string>
<string name="boot_movie_repo_downloads">Най-изтеглени</string>
<string name="boot_movie_repo_likes">Най-харесвани</string>
<string name="boot_movie_repo_newest">Най-нови</string>
<string name="boot_movie_repo_failed">SteamDeckRepo не може да бъде достигнат</string>
<string name="boot_movie_repo_retry">Опитайте отново</string>
<string name="boot_movie_repo_empty">Не са намерени видеа</string>
<string name="boot_movie_repo_install">Инсталиране</string>
<string name="boot_movie_repo_use">Използване</string>
<string name="boot_movie_in_use">Използва се</string>
<string name="boot_movie_repo_installed">%1$s е инсталирано</string>
<string name="boot_movie_repo_download_failed">Видеото не можа да бъде изтеглено</string>
<string name="boot_movie_repo_previous">Назад</string>
<string name="boot_movie_repo_next">Напред</string>
<string name="boot_movie_repo_page">Страница %1$d</string>
<string name="boot_movie_repo_clear">Изчисти</string>
<string name="boot_movie_repo_go_to_page">Към страница</string>
<string name="boot_movie_repo_preview">Преглед</string>
<string name="boot_movie_repo_preview_failed">Прегледът не можа да бъде пуснат</string>
<string name="glasses_experimental">Luma Ultra камери — Експериментално</string>
<string name="glasses_joycons">Проследяване на позицията на Joy-Con</string>
<string name="glasses_joycons_hint">Използвайте камерите на интелигентните очила, за да позиционирате сдвоените си контролери Joy-Con. Въртенето и натискането на бутоните идват от контролерите Joy-Con. Позицията се оценява, когато китките ви са скрити. Заменя контролерите, управлявани с голи ръце, и поставя на пауза проследяването с камерата на телефона. Насочете контролерите Joy-Con напред, когато свързвате или нулирате центъра за проследяване.</string>
@@ -2866,7 +2913,6 @@
<string name="spatial_hands_wait_stable">Изчакване на стабилно проследяване на главата; нулиране на центъра за проследяване, ако е поискано.</string>
<string name="spatial_old_frame">Наблюдението на камерата е твърде старо; в очакване на нов чифт</string>
<string name="spatial_fit_ready">Побиране на %1$.2f пиксела. Измерете избраното разстояние от лявата камера до центъра на целта за калибриране, след което потвърдете.</string>
<string name="spatial_opencv_error">OpenCV не е наличен: %1$s</string>
<string name="spatial_restored">%1$d запазените изгледи са възстановени. Изчислете калибрирането, за да опитате отново, или започнете нов набор, за да заснемете повече.</string>
<string name="spatial_captured">Заснето %1$d / 24 прегледа. Дръжте целта за калибриране фиксирана; преместете смарт очилата за следващия изглед.</string>
<string name="spatial_capture_corners">Заснемането е отхвърлено: %1$d / 12 задължителни ъгъла, видими и в двете камери. Покажете още от целта за калибриране, задръжте неподвижно и опитайте отново.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Gesta</string>
<string name="gesture_settings">Nastavení dotykových gest</string>
<string name="gesture_settings_hint">Přizpůsobte gesta myši a klávesnice. Chcete-li je používat, vyberte dotykový režim Gesta.</string>
<string name="gesture_tap">Klepnutí</string>
<string name="gesture_drag">Tažení jedním prstem</string>
<string name="gesture_long_press">Dlouhé stisknutí</string>
<string name="gesture_double_tap">Dvojité klepnutí (dvojklik levým tlačítkem)</string>
<string name="gesture_two_tap">Klepnutí dvěma prsty</string>
<string name="gesture_two_drag">Tažení dvěma prsty</string>
<string name="gesture_two_hold">Podržení dvěma prsty</string>
<string name="gesture_three_tap">Klepnutí třemi prsty</string>
<string name="gesture_three_drag">Tažení třemi prsty</string>
<string name="gesture_three_hold">Podržení třemi prsty</string>
<string name="gesture_pinch">Přiblížení sevřením či rozevřením prstů</string>
<string name="gesture_hold_button">Držet tlačítko stisknuté</string>
<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>
<string name="gesture_left_click">Levé kliknutí</string>
<string name="gesture_right_click">Pravé kliknutí</string>
<string name="gesture_middle_click">Prostřední kliknutí</string>
<string name="gesture_keyboard">Zobrazit klávesnici</string>
<string name="gesture_left_drag">Levé kliknutí a tažení</string>
<string name="gesture_right_drag">Pravé kliknutí a tažení</string>
<string name="gesture_middle_pan">Posouvání prostředním tlačítkem myši</string>
<string name="gesture_middle_pan_inverted">Posouvání prostředním tlačítkem myši (obráceně)</string>
<string name="gesture_arrows">Šipky</string>
<string name="gesture_arrows_inverted">Šipky (obráceně)</string>
<string name="gesture_wasd_inverted">WASD (obráceně)</string>
<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>
<string name="gesture_no_actions">Žádné odpovídající akce. Zkuste jiný název klávesy nebo akce.</string>
<string name="gesture_group_mouse">Myš</string>
<string name="gesture_group_app">Akce aplikace</string>
<string name="gesture_group_navigation">Navigace</string>
<string name="gesture_group_letters">Písmena</string>
<string name="gesture_group_numbers">Číslice</string>
<string name="gesture_group_function">Funkční klávesy</string>
<string name="gesture_group_modifiers">Modifikační klávesy</string>
<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>
<string name="gesture_key_numpad">Numerická klávesnice %1$s</string>
<string name="gesture_key_escape">Esc</string>
<string name="gesture_key_backspace">Backspace</string>
<string name="gesture_key_enter">Enter</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">Delete</string>
<string name="gesture_key_insert">Insert</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">Page Up</string>
<string name="gesture_key_page_down">Page Down</string>
<string name="gesture_key_print_screen">Print Screen</string>
<string name="gesture_key_up">Šipka nahoru</string>
<string name="gesture_key_down">Šipka dolů</string>
<string name="gesture_key_left_arrow">Šipka vlevo</string>
<string name="gesture_key_right_arrow">Šipka vpravo</string>
<string name="gesture_key_caps_lock">Caps Lock</string>
<string name="gesture_key_num_lock">Num Lock</string>
<string name="gesture_key_scroll_lock">Scroll Lock</string>
<string name="gesture_key_left">Levý %1$s</string>
<string name="gesture_key_right">Pravý %1$s</string>
<string name="gesture_key_shift">Shift</string>
</resources>
+50 -4
View File
@@ -542,7 +542,7 @@
<string name="perf_gpuclock_hint">Jen Adreno. Méně zadrhávání, více spotřeby baterie a tepla.</string>
<string name="perf_fixes">Opravy relace</string>
<string name="perf_sysmem">Turnip: vykreslování sysmem</string>
<string name="perf_sysmem_hint">Rychlejší pro Steam a většinu her. Vyžadováno na Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">Rychlejší pro klienta Steam; ve hrách ovladač pro každý průchod volí dlaždicové vykreslování, nebo vykreslování v systémové paměti. Vyžadováno na Adreno 710/720/722.</string>
<string name="perf_xalia">Vynechat Steamova pomocníka xalia</string>
<string name="perf_fastsync">droiddeck-ntsync (experimentální)</string>
<string name="perf_fastsync_hint">Uživatelská náhrada linuxového ovladače ntsync, pro testování. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace.</string>
@@ -1966,6 +1966,8 @@
<string name="stores_title">Obchody</string>
<string name="stores_settings">Nastavení obchodů</string>
<string name="stores_settings_eyebrow">Nastavení</string>
<string name="stores_tabs_arrange">Uspořádat karty</string>
<string name="stores_tabs_arrange_done">Hotovo</string>
<string name="stores_chip_signed_out">(nepřihlášen)</string>
<string name="stores_tab_store">Obchod</string>
<string name="stores_tab_library">Knihovna</string>
@@ -2192,7 +2194,7 @@
<string name="logo_pos_size">Velikost</string>
<string name="logo_pos_save">Uložit</string>
<string name="logo_pos_reset">Zpět na výchozí</string>
<string name="logo_pos_hint">Kreslí se přes pozadí hry, jako to dělá Steam. Přesná poloha se může lišit o pár pixelů.</string>
<string name="logo_pos_hint">Přesná poloha se může lišit o několik pixelů.</string>
<string name="logo_pos_no_logo">Tato hra zatím nemá logo. Vyber ho v Logo, ať se tady zobrazí.</string>
<string name="logo_pos_failed">Polohu loga se nepodařilo uložit. Zkus to znovu.</string>
<string name="art_background">Pozadí</string>
@@ -2747,7 +2749,7 @@
<string name="boot_movie">Úvodní video</string>
<string name="boot_movie_video">Video</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">60 fps vypadá plynuleji, ale při spouštění Steamu víc zatěžuje procesor. Úvodní video vybrané přímo v nastavení Steamu má přednost.</string>
<string name="boot_movie_note">60 fps vypadá plynuleji, ale při spouštění Steamu víc zatěžuje procesor. Video vybrané přímo v nastavení Steamu se zde také zobrazí.</string>
<string name="boot_movie_sound">Zvuk</string>
<string name="boot_movie_custom">Vlastní video</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV nebo WebM. Jiné formáty a soubory nad %1$d MB se v zařízení převedou na WebM 720p, nejvýše %2$d sekund.</string>
@@ -2761,6 +2763,51 @@
<string name="boot_movie_converting_started">Převádí se %1$s pro Steam. Nechte DroidDeck otevřený.</string>
<string name="boot_movie_unsupported">Toto zařízení video nedokáže dekódovat.</string>
<string name="boot_movie_import_failed">Video se nepodařilo importovat</string>
<string name="boot_movie_remove_confirm">Odebrat %1$s?</string>
<string name="boot_movie_remove_confirm_body">Tím se video smaže z DroidDecku i ze složky videí Steamu.</string>
<string name="boot_movie_tab_installed">Nainstalované</string>
<string name="boot_movie_tab_import">Importovat</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Zvuk</string>
<string name="boot_movie_installed">Nainstalovaná videa</string>
<string name="boot_movie_browse">Procházet</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Importované</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Obchod s body</string>
<string name="boot_movie_report">Zpráva o úvodních videích</string>
<string name="boot_movie_report_hint">Uloží do textového souboru, co DroidDeck a Steam vědí o úvodních videích, aby šel přiložit k hlášení chyby.</string>
<string name="boot_movie_report_save">Uložit</string>
<string name="boot_movie_report_saved">Uloženo do %1$s</string>
<string name="boot_movie_report_failed">Zprávu se nepodařilo uložit.</string>
<string name="boot_movie_volume">Hlasitost</string>
<string name="boot_movie_length">Délka</string>
<string name="boot_movie_seconds">%1$d s</string>
<string name="boot_movie_length_hint">Kolik sekund filmu se přehraje. Cokoli delšího se na konci odřízne.</string>
<string name="boot_movie_repo_search">Hledat v SteamDeckRepo</string>
<string name="boot_movie_repo_all">Všechna zařízení</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Populární</string>
<string name="boot_movie_repo_downloads">Nejstahovanější</string>
<string name="boot_movie_repo_likes">Nejoblíbenější</string>
<string name="boot_movie_repo_newest">Nejnovější</string>
<string name="boot_movie_repo_failed">SteamDeckRepo se nepodařilo kontaktovat</string>
<string name="boot_movie_repo_retry">Zkusit znovu</string>
<string name="boot_movie_repo_empty">Nenalezena žádná videa</string>
<string name="boot_movie_repo_install">Nainstalovat</string>
<string name="boot_movie_repo_use">Použít</string>
<string name="boot_movie_in_use">Používá se</string>
<string name="boot_movie_repo_installed">%1$s nainstalováno</string>
<string name="boot_movie_repo_download_failed">Video se nepodařilo stáhnout</string>
<string name="boot_movie_repo_previous">Předchozí</string>
<string name="boot_movie_repo_next">Další</string>
<string name="boot_movie_repo_page">Strana %1$d</string>
<string name="boot_movie_repo_clear">Vymazat</string>
<string name="boot_movie_repo_go_to_page">Přejít na stránku</string>
<string name="boot_movie_repo_preview">Náhled</string>
<string name="boot_movie_repo_preview_failed">Náhled se nepodařilo přehrát</string>
<string name="glasses_experimental">Kamery Luma Ultra — Experimentální</string>
<string name="glasses_joycons">Sledování polohy Joy-Conů</string>
<string name="glasses_joycons_hint">Použijte kamery chytrých brýlí k umístění spárovaných ovladačů Joy-Con. Otáčení a vstupy tlačítek pocházejí z ovladačů Joy-Con. Poloha se odhaduje, když máte skrytá zápěstí. Nahrazuje ovladače ovládané holýma rukama a pozastavuje sledování fotoaparátem telefonu. Při připojování nebo resetování centra sledování namiřte ovladače Joy-Con dopředu.</string>
@@ -2934,7 +2981,6 @@
<string name="spatial_hands_wait_stable">Čekání na stabilní sledování hlavy; resetování centra sledování je možné, pokud je požadováno.</string>
<string name="spatial_old_frame">Pozorování kamerou je příliš staré; čekání na nový pár</string>
<string name="spatial_fit_ready">Přizpůsobit %1$.2f pixelům. Změřte vybranou vzdálenost od levé kamery ke středu kalibračního cíle a poté potvrďte.</string>
<string name="spatial_opencv_error">OpenCV není k dispozici: %1$s</string>
<string name="spatial_restored">Bylo obnoveno %1$d uložených zobrazení. Vypočítejte kalibraci a zkuste to znovu, nebo spusťte novou sadu a zachyťte více.</string>
<string name="spatial_captured">Pořízeno %1$d / 24 zobrazení. Udržujte kalibrační cíl pevný; posuňte chytré brýle pro další pohled.</string>
<string name="spatial_capture_corners">Snímek zamítnut: %1$d / 12 požadovaných rohů viditelných v obou kamerách. Ukažte více z kalibračního cíle, nehýbejte se a zkuste to znovu.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Bevægelser</string>
<string name="gesture_settings">Indstillinger for berøringsbevægelser</string>
<string name="gesture_settings_hint">Tilpas muse- og tastaturbevægelser. Vælg Bevægelser som berøringstilstand for at bruge dem.</string>
<string name="gesture_tap">Tryk</string>
<string name="gesture_drag">Træk med én finger</string>
<string name="gesture_long_press">Langt tryk</string>
<string name="gesture_double_tap">Dobbelttryk (dobbelt venstreklik)</string>
<string name="gesture_two_tap">Tryk med to fingre</string>
<string name="gesture_two_drag">Træk med to fingre</string>
<string name="gesture_two_hold">Hold med to fingre</string>
<string name="gesture_three_tap">Tryk med tre fingre</string>
<string name="gesture_three_drag">Træk med tre fingre</string>
<string name="gesture_three_hold">Hold med tre fingre</string>
<string name="gesture_pinch">Knib for at zoome</string>
<string name="gesture_hold_button">Hold knappen nede</string>
<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>
<string name="gesture_left_click">Venstreklik</string>
<string name="gesture_right_click">Højreklik</string>
<string name="gesture_middle_click">Midterklik</string>
<string name="gesture_keyboard">Vis tastatur</string>
<string name="gesture_left_drag">Venstreklik og træk</string>
<string name="gesture_right_drag">Højreklik og træk</string>
<string name="gesture_middle_pan">Panorering med midterste museknap</string>
<string name="gesture_middle_pan_inverted">Panorering med midterste museknap (omvendt)</string>
<string name="gesture_arrows">Piletaster</string>
<string name="gesture_arrows_inverted">Piletaster (omvendt)</string>
<string name="gesture_wasd_inverted">WASD (omvendt)</string>
<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>
<string name="gesture_no_actions">Ingen matchende handlinger. Prøv et andet navn på en tast eller handling.</string>
<string name="gesture_group_mouse">Mus</string>
<string name="gesture_group_app">Apphandlinger</string>
<string name="gesture_group_navigation">Navigation</string>
<string name="gesture_group_letters">Bogstaver</string>
<string name="gesture_group_numbers">Tal</string>
<string name="gesture_group_function">Funktionstaster</string>
<string name="gesture_group_modifiers">Modifikationstaster</string>
<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>
<string name="gesture_key_numpad">Numerisk tastatur %1$s</string>
<string name="gesture_key_escape">Escape</string>
<string name="gesture_key_backspace">Tilbage</string>
<string name="gesture_key_enter">Enter</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">Delete</string>
<string name="gesture_key_insert">Insert</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">Page Up</string>
<string name="gesture_key_page_down">Page Down</string>
<string name="gesture_key_print_screen">Print Screen</string>
<string name="gesture_key_up">Pil op</string>
<string name="gesture_key_down">Pil ned</string>
<string name="gesture_key_left_arrow">Pil til venstre</string>
<string name="gesture_key_right_arrow">Pil til højre</string>
<string name="gesture_key_caps_lock">Caps Lock</string>
<string name="gesture_key_num_lock">Num Lock</string>
<string name="gesture_key_scroll_lock">Scroll Lock</string>
<string name="gesture_key_left">Venstre %1$s</string>
<string name="gesture_key_right">Højre %1$s</string>
<string name="gesture_key_shift">Skift</string>
</resources>
+50 -4
View File
@@ -538,7 +538,7 @@
<string name="perf_gpuclock_hint">Kun Adreno. Færre hak, mere batteribrug og varme.</string>
<string name="perf_fixes">Sessionsrettelser</string>
<string name="perf_sysmem">Turnip: sysmem-rendering</string>
<string name="perf_sysmem_hint">Hurtigere til Steam og de fleste spil. Påkrævet på Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">Hurtigere til Steam-klienten; i spil vælger driveren flise- eller systemhukommelses-rendering for hvert pass. Påkrævet på Adreno 710/720/722.</string>
<string name="perf_xalia">Spring Steams xalia-hjælper over</string>
<string name="perf_fastsync">droiddeck-ntsync (eksperimentel)</string>
<string name="perf_fastsync_hint">En stand-in til ntsync-driveren i Linux-kernen, i brugerrum, til test. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session.</string>
@@ -1924,6 +1924,8 @@
<string name="stores_title">Butikker</string>
<string name="stores_settings">Butiksindstillinger</string>
<string name="stores_settings_eyebrow">Indstillinger</string>
<string name="stores_tabs_arrange">Arranger faner</string>
<string name="stores_tabs_arrange_done">Færdig</string>
<string name="stores_chip_signed_out">(ikke logget ind)</string>
<string name="stores_tab_store">Butik</string>
<string name="stores_tab_library">Bibliotek</string>
@@ -2150,7 +2152,7 @@
<string name="logo_pos_size">Størrelse</string>
<string name="logo_pos_save">Gem</string>
<string name="logo_pos_reset">Tilbage til standard</string>
<string name="logo_pos_hint">Tegnes oven på spillets baggrund, som Steam gør. Den præcise placering kan afvige et par pixels.</string>
<string name="logo_pos_hint">Præcis placering kan afvige med nogle få pixel.</string>
<string name="logo_pos_no_logo">Dette spil har endnu ikke et logo. Vælg et under Logo for at se det her.</string>
<string name="logo_pos_failed">Logopladeringen kunne ikke gemmes. Prøv igen.</string>
<string name="art_background">Baggrund</string>
@@ -2679,7 +2681,7 @@
<string name="boot_movie">Opstartsvideo</string>
<string name="boot_movie_video">Video</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">60 fps ser mere flydende ud, men bruger mere CPU, mens Steam starter. En opstartsvideo valgt i Steams egne indstillinger har forrang.</string>
<string name="boot_movie_note">60 fps ser mere flydende ud, men bruger mere CPU, mens Steam starter. En video valgt i Steams egne indstillinger vises også her.</string>
<string name="boot_movie_sound">Lyd</string>
<string name="boot_movie_custom">Din egen video</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV eller WebM. Andre formater og filer over %1$d MB konverteres til 720p-WebM på enheden, op til %2$d sekunder.</string>
@@ -2693,6 +2695,51 @@
<string name="boot_movie_converting_started">Konverterer %1$s til Steam. Hold DroidDeck åben.</string>
<string name="boot_movie_unsupported">Enheden kan ikke afkode videoen.</string>
<string name="boot_movie_import_failed">Kunne ikke importere videoen</string>
<string name="boot_movie_remove_confirm">Fjern %1$s?</string>
<string name="boot_movie_remove_confirm_body">Dette sletter videoen fra DroidDeck og fra Steams videomappe.</string>
<string name="boot_movie_tab_installed">Installeret</string>
<string name="boot_movie_tab_import">Importér</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Lyd</string>
<string name="boot_movie_installed">Installerede videoer</string>
<string name="boot_movie_browse">Gennemse</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Importeret</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Pointbutik</string>
<string name="boot_movie_report">Rapport om startfilm</string>
<string name="boot_movie_report_hint">Gemmer, hvad DroidDeck og Steam ved om startfilm, i en tekstfil, som du kan vedhæfte en fejlrapport.</string>
<string name="boot_movie_report_save">Gem</string>
<string name="boot_movie_report_saved">Gemt i %1$s</string>
<string name="boot_movie_report_failed">Rapporten kunne ikke gemmes.</string>
<string name="boot_movie_volume">Lydstyrke</string>
<string name="boot_movie_length">Længde</string>
<string name="boot_movie_seconds">%1$d s</string>
<string name="boot_movie_length_hint">Hvor mange sekunder af filmen der afspilles. Alt, hvad der er længere, klippes af til sidst.</string>
<string name="boot_movie_repo_search">Søg i SteamDeckRepo</string>
<string name="boot_movie_repo_all">Alle enheder</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Populære</string>
<string name="boot_movie_repo_downloads">Mest downloadet</string>
<string name="boot_movie_repo_likes">Mest likede</string>
<string name="boot_movie_repo_newest">Nyeste</string>
<string name="boot_movie_repo_failed">Kunne ikke få forbindelse til SteamDeckRepo</string>
<string name="boot_movie_repo_retry">Prøv igen</string>
<string name="boot_movie_repo_empty">Ingen videoer fundet</string>
<string name="boot_movie_repo_install">Installér</string>
<string name="boot_movie_repo_use">Brug</string>
<string name="boot_movie_in_use">I brug</string>
<string name="boot_movie_repo_installed">%1$s installeret</string>
<string name="boot_movie_repo_download_failed">Kunne ikke downloade videoen</string>
<string name="boot_movie_repo_previous">Forrige</string>
<string name="boot_movie_repo_next">Næste</string>
<string name="boot_movie_repo_page">Side %1$d</string>
<string name="boot_movie_repo_clear">Ryd</string>
<string name="boot_movie_repo_go_to_page">Gå til side</string>
<string name="boot_movie_repo_preview">Forhåndsvis</string>
<string name="boot_movie_repo_preview_failed">Forhåndsvisningen kunne ikke afspilles</string>
<string name="glasses_experimental">Luma Ultra-kameraer — Eksperimentel</string>
<string name="glasses_joycons">Joy-Con-positionssporing</string>
<string name="glasses_joycons_hint">Brug smartglass-kameraerne til at placere dine parrede Joy-Con-controllere. Rotation og knapinput kommer fra Joy-Con-controllerne. Positionen estimeres, når dine håndled er skjulte. Erstatter controllere, der betjenes med bare hænder, og sætter telefonkameraets sporing på pause. Ret Joy-Con-controllerne fremad, når du tilslutter eller nulstiller sporingscentret.</string>
@@ -2866,7 +2913,6 @@
<string name="spatial_hands_wait_stable">Venter på stabil hovedsporing; nulstil sporingscenter, hvis det anmodes om.</string>
<string name="spatial_old_frame">Kameraobservation er for gammel; venter på et nyt par</string>
<string name="spatial_fit_ready">Tilpas %1$.2f px. Mål din valgte afstand fra venstre kamera til kalibreringsmålets centrum, og valider derefter.</string>
<string name="spatial_opencv_error">OpenCV utilgængelig: %1$s</string>
<string name="spatial_restored">%1$d gemte visninger gendannet. Beregn kalibrering for at prøve igen, eller start et nyt sæt for at fange mere.</string>
<string name="spatial_captured">Optaget %1$d / 24 visninger. Hold kalibreringsmålet fast; flyt de smarte briller til næste visning.</string>
<string name="spatial_capture_corners">Optagelse afvist: %1$d / 12 påkrævede hjørner synlige i begge kameraer. Vis mere af kalibreringsmålet, hold stille, og prøv derefter igen.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Gesten</string>
<string name="gesture_settings">Touchgesten-Einstellungen</string>
<string name="gesture_settings_hint">Maus- und Tastaturgesten anpassen. Wähle Gesten als Touchmodus, um sie zu verwenden.</string>
<string name="gesture_tap">Tippen</string>
<string name="gesture_drag">Mit einem Finger ziehen</string>
<string name="gesture_long_press">Lange drücken</string>
<string name="gesture_double_tap">Doppeltippen (doppelter Linksklick)</string>
<string name="gesture_two_tap">Mit zwei Fingern tippen</string>
<string name="gesture_two_drag">Mit zwei Fingern ziehen</string>
<string name="gesture_two_hold">Mit zwei Fingern halten</string>
<string name="gesture_three_tap">Mit drei Fingern tippen</string>
<string name="gesture_three_drag">Mit drei Fingern ziehen</string>
<string name="gesture_three_hold">Mit drei Fingern halten</string>
<string name="gesture_pinch">Zum Zoomen Finger zusammen- oder auseinanderziehen</string>
<string name="gesture_hold_button">Taste gedrückt halten</string>
<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>
<string name="gesture_left_click">Linksklick</string>
<string name="gesture_right_click">Rechtsklick</string>
<string name="gesture_middle_click">Mittelklick</string>
<string name="gesture_keyboard">Tastatur einblenden</string>
<string name="gesture_left_drag">Linksklick und ziehen</string>
<string name="gesture_right_drag">Rechtsklick und ziehen</string>
<string name="gesture_middle_pan">Mit mittlerer Maustaste schwenken</string>
<string name="gesture_middle_pan_inverted">Mit mittlerer Maustaste schwenken (umgekehrt)</string>
<string name="gesture_arrows">Pfeiltasten</string>
<string name="gesture_arrows_inverted">Pfeiltasten (umgekehrt)</string>
<string name="gesture_wasd_inverted">WASD (umgekehrt)</string>
<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>
<string name="gesture_no_actions">Keine passenden Aktionen. Versuche einen anderen Tasten- oder Aktionsnamen.</string>
<string name="gesture_group_mouse">Maus</string>
<string name="gesture_group_app">App-Aktionen</string>
<string name="gesture_group_navigation">Navigation</string>
<string name="gesture_group_letters">Buchstaben</string>
<string name="gesture_group_numbers">Zahlen</string>
<string name="gesture_group_function">Funktionstasten</string>
<string name="gesture_group_modifiers">Modifikatortasten</string>
<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>
<string name="gesture_key_numpad">Ziffernblock %1$s</string>
<string name="gesture_key_escape">Esc</string>
<string name="gesture_key_backspace">Rücktaste</string>
<string name="gesture_key_enter">Eingabetaste</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Leertaste</string>
<string name="gesture_key_delete">Entf</string>
<string name="gesture_key_insert">Einfg</string>
<string name="gesture_key_home">Pos1</string>
<string name="gesture_key_end">Ende</string>
<string name="gesture_key_page_up">Bild auf</string>
<string name="gesture_key_page_down">Bild ab</string>
<string name="gesture_key_print_screen">Druck</string>
<string name="gesture_key_up">Pfeil nach oben</string>
<string name="gesture_key_down">Pfeil nach unten</string>
<string name="gesture_key_left_arrow">Pfeil nach links</string>
<string name="gesture_key_right_arrow">Pfeil nach rechts</string>
<string name="gesture_key_caps_lock">Feststelltaste</string>
<string name="gesture_key_num_lock">Num-Feststelltaste</string>
<string name="gesture_key_scroll_lock">Rollen-Feststelltaste</string>
<string name="gesture_key_left">Linke %1$s</string>
<string name="gesture_key_right">Rechte %1$s</string>
<string name="gesture_key_shift">Umschalt</string>
</resources>
+50 -4
View File
@@ -535,7 +535,7 @@
<string name="perf_gpuclock_hint">Nur Adreno. Weniger Ruckler, mehr Akkuverbrauch und Wärme.</string>
<string name="perf_fixes">Sitzungskorrekturen</string>
<string name="perf_sysmem">Turnip: Sysmem-Rendering</string>
<string name="perf_sysmem_hint">Schneller für Steam und die meisten Spiele. Erforderlich bei Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">Schneller für den Steam-Client; in Spielen wählt der Treiber pro Durchgang zwischen Kachel- und Systemspeicher-Rendering. Erforderlich bei Adreno 710/720/722.</string>
<string name="perf_xalia">Steams xalia-Helfer überspringen</string>
<string name="perf_fastsync">droiddeck-ntsync (experimentell)</string>
<string name="perf_fastsync_hint">Ein Userspace-Ersatz für den Linux-Treiber ntsync, zum Testen. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung.</string>
@@ -1913,6 +1913,8 @@
<string name="stores_title">Stores</string>
<string name="stores_settings">Store-Einstellungen</string>
<string name="stores_settings_eyebrow">Einstellungen</string>
<string name="stores_tabs_arrange">Tabs anordnen</string>
<string name="stores_tabs_arrange_done">Fertig</string>
<string name="stores_chip_signed_out">(nicht angemeldet)</string>
<string name="stores_tab_store">Store</string>
<string name="stores_tab_library">Bibliothek</string>
@@ -2136,7 +2138,7 @@
<string name="logo_pos_size">Größe</string>
<string name="logo_pos_save">Speichern</string>
<string name="logo_pos_reset">Auf Standard zurücksetzen</string>
<string name="logo_pos_hint">Wird wie bei Steam über dem Spielehintergrund gezeichnet. Die Position kann um einige Pixel abweichen.</string>
<string name="logo_pos_hint">Die genaue Position kann um einige Pixel abweichen.</string>
<string name="logo_pos_no_logo">Für dieses Spiel gibt es noch kein Logo. Wähle unter Logo eines aus, um es hier zu sehen.</string>
<string name="logo_pos_failed">Die Logoposition ließ sich nicht speichern. Versuche es erneut.</string>
<string name="art_background">Hintergrund</string>
@@ -2679,7 +2681,7 @@
<string name="boot_movie">Bootvideo</string>
<string name="boot_movie_video">Video</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">60 fps wirkt flüssiger, braucht aber mehr CPU, während Steam startet. Ein in Steam selbst gewähltes Startvideo hat Vorrang.</string>
<string name="boot_movie_note">60 fps wirkt flüssiger, braucht aber mehr CPU, während Steam startet. Ein in Steam selbst gewähltes Video erscheint hier ebenfalls.</string>
<string name="boot_movie_sound">Ton</string>
<string name="boot_movie_custom">Eigenes Video</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV oder WebM. Andere Formate und Dateien über %1$d MB werden auf dem Gerät in 720p-WebM umgewandelt, bis %2$d Sekunden.</string>
@@ -2693,6 +2695,51 @@
<string name="boot_movie_converting_started">%1$s wird für Steam umgewandelt. DroidDeck geöffnet lassen.</string>
<string name="boot_movie_unsupported">Dieses Gerät kann das Video nicht dekodieren.</string>
<string name="boot_movie_import_failed">Das Video konnte nicht importiert werden</string>
<string name="boot_movie_remove_confirm">%1$s entfernen?</string>
<string name="boot_movie_remove_confirm_body">Dadurch wird das Video aus DroidDeck und aus dem Videoordner von Steam gelöscht.</string>
<string name="boot_movie_tab_installed">Installiert</string>
<string name="boot_movie_tab_import">Importieren</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Ton</string>
<string name="boot_movie_installed">Installierte Videos</string>
<string name="boot_movie_browse">Durchsuchen</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Importiert</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Punkteshop</string>
<string name="boot_movie_report">Bericht zu Startfilmen</string>
<string name="boot_movie_report_hint">Speichert in einer Textdatei, was DroidDeck und Steam über Startfilme wissen, zum Anhängen an einen Fehlerbericht.</string>
<string name="boot_movie_report_save">Speichern</string>
<string name="boot_movie_report_saved">Gespeichert unter %1$s</string>
<string name="boot_movie_report_failed">Der Bericht konnte nicht gespeichert werden.</string>
<string name="boot_movie_volume">Lautstärke</string>
<string name="boot_movie_length">Länge</string>
<string name="boot_movie_seconds">%1$d s</string>
<string name="boot_movie_length_hint">Wie viele Sekunden des Films abgespielt werden. Alles, was länger ist, wird am Ende abgeschnitten.</string>
<string name="boot_movie_repo_search">SteamDeckRepo durchsuchen</string>
<string name="boot_movie_repo_all">Alle Geräte</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Im Trend</string>
<string name="boot_movie_repo_downloads">Meistgeladen</string>
<string name="boot_movie_repo_likes">Beliebteste</string>
<string name="boot_movie_repo_newest">Neueste</string>
<string name="boot_movie_repo_failed">SteamDeckRepo ist nicht erreichbar</string>
<string name="boot_movie_repo_retry">Erneut versuchen</string>
<string name="boot_movie_repo_empty">Keine Videos gefunden</string>
<string name="boot_movie_repo_install">Installieren</string>
<string name="boot_movie_repo_use">Verwenden</string>
<string name="boot_movie_in_use">In Verwendung</string>
<string name="boot_movie_repo_installed">%1$s installiert</string>
<string name="boot_movie_repo_download_failed">Das Video konnte nicht heruntergeladen werden</string>
<string name="boot_movie_repo_previous">Zurück</string>
<string name="boot_movie_repo_next">Weiter</string>
<string name="boot_movie_repo_page">Seite %1$d</string>
<string name="boot_movie_repo_clear">Leeren</string>
<string name="boot_movie_repo_go_to_page">Zur Seite</string>
<string name="boot_movie_repo_preview">Vorschau</string>
<string name="boot_movie_repo_preview_failed">Die Vorschau konnte nicht abgespielt werden</string>
<string name="glasses_experimental">Luma Ultra-Kameras – Experimentell</string>
<string name="glasses_joycons">Joy-Con-Positionsverfolgung</string>
<string name="glasses_joycons_hint">Positionieren Sie Ihre gekoppelten Joy-Con-Controller mithilfe der Kameras Ihrer Smartbrille. Drehung und Tasteneingaben erfolgen über die Joy-Con-Controller. Die Position wird geschätzt, wenn Ihre Handgelenke verdeckt sind. Ersetzt Controller, die mit bloßen Händen bedient werden, und pausiert die Verfolgung per Smartphone-Kamera. Richten Sie die Joy-Con-Controller nach vorn, wenn Sie die Verbindung herstellen oder das Tracking-Center zurücksetzen.</string>
@@ -2866,7 +2913,6 @@
<string name="spatial_hands_wait_stable">Warten auf stabiles Head-Tracking; Tracking-Zentrum bei Bedarf zurücksetzen.</string>
<string name="spatial_old_frame">Kamerabeobachtung ist zu alt; warte auf ein neues Paar</string>
<string name="spatial_fit_ready">%1$.2f px anpassen. Messen Sie den ausgewählten Abstand von der linken Kamera zum Zentrum des Kalibrierungsziels und bestätigen Sie ihn anschließend.</string>
<string name="spatial_opencv_error">OpenCV nicht verfügbar: %1$s</string>
<string name="spatial_restored">%1$d gespeicherte Ansichten wiederhergestellt. Berechnen Sie die Kalibrierung, um es erneut zu versuchen, oder starten Sie einen neuen Satz, um mehr zu erfassen.</string>
<string name="spatial_captured">Aufgenommen %1$d / 24 Aufrufe. Halten Sie das Kalibrierungsziel fest; Bewegen Sie die Datenbrille für die nächste Ansicht.</string>
<string name="spatial_capture_corners">Aufnahme abgelehnt: %1$d / 12 erforderliche Ecken in beiden Kameras sichtbar. Zeigen Sie mehr vom Kalibrierungsziel, halten Sie still und versuchen Sie es dann erneut.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Χειρονομίες</string>
<string name="gesture_settings">Ρυθμίσεις χειρονομιών αφής</string>
<string name="gesture_settings_hint">Προσαρμόστε τις χειρονομίες ποντικιού και πληκτρολογίου. Επιλέξτε Χειρονομίες ως λειτουργία αφής για να τις χρησιμοποιήσετε.</string>
<string name="gesture_tap">Πάτημα</string>
<string name="gesture_drag">Σύρσιμο με ένα δάχτυλο</string>
<string name="gesture_long_press">Παρατεταμένο πάτημα</string>
<string name="gesture_double_tap">Διπλό πάτημα (διπλό αριστερό κλικ)</string>
<string name="gesture_two_tap">Πάτημα με δύο δάχτυλα</string>
<string name="gesture_two_drag">Σύρσιμο με δύο δάχτυλα</string>
<string name="gesture_two_hold">Κράτημα με δύο δάχτυλα</string>
<string name="gesture_three_tap">Πάτημα με τρία δάχτυλα</string>
<string name="gesture_three_drag">Σύρσιμο με τρία δάχτυλα</string>
<string name="gesture_three_hold">Κράτημα με τρία δάχτυλα</string>
<string name="gesture_pinch">Μεγέθυνση με τσίμπημα</string>
<string name="gesture_hold_button">Διατήρηση του κουμπιού πατημένου</string>
<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>
<string name="gesture_left_click">Αριστερό κλικ</string>
<string name="gesture_right_click">Δεξί κλικ</string>
<string name="gesture_middle_click">Μεσαίο κλικ</string>
<string name="gesture_keyboard">Εμφάνιση πληκτρολογίου</string>
<string name="gesture_left_drag">Αριστερό κλικ και σύρσιμο</string>
<string name="gesture_right_drag">Δεξί κλικ και σύρσιμο</string>
<string name="gesture_middle_pan">Μετατόπιση με το μεσαίο κουμπί</string>
<string name="gesture_middle_pan_inverted">Μετατόπιση με το μεσαίο κουμπί (αντίστροφη)</string>
<string name="gesture_arrows">Πλήκτρα βέλους</string>
<string name="gesture_arrows_inverted">Πλήκτρα βέλους (αντίστροφα)</string>
<string name="gesture_wasd_inverted">WASD (αντίστροφα)</string>
<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>
<string name="gesture_no_actions">Δεν βρέθηκαν ενέργειες. Δοκιμάστε άλλο όνομα πλήκτρου ή ενέργειας.</string>
<string name="gesture_group_mouse">Ποντίκι</string>
<string name="gesture_group_app">Ενέργειες εφαρμογής</string>
<string name="gesture_group_navigation">Πλοήγηση</string>
<string name="gesture_group_letters">Γράμματα</string>
<string name="gesture_group_numbers">Αριθμοί</string>
<string name="gesture_group_function">Πλήκτρα λειτουργιών</string>
<string name="gesture_group_modifiers">Πλήκτρα τροποποίησης</string>
<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>
<string name="gesture_key_numpad">Αριθμητικό πληκτρολόγιο %1$s</string>
<string name="gesture_key_escape">Escape</string>
<string name="gesture_key_backspace">Backspace</string>
<string name="gesture_key_enter">Enter</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">Delete</string>
<string name="gesture_key_insert">Insert</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">Page Up</string>
<string name="gesture_key_page_down">Page Down</string>
<string name="gesture_key_print_screen">Print Screen</string>
<string name="gesture_key_up">Βέλος πάνω</string>
<string name="gesture_key_down">Βέλος κάτω</string>
<string name="gesture_key_left_arrow">Βέλος αριστερά</string>
<string name="gesture_key_right_arrow">Βέλος δεξιά</string>
<string name="gesture_key_caps_lock">Caps Lock</string>
<string name="gesture_key_num_lock">Num Lock</string>
<string name="gesture_key_scroll_lock">Scroll Lock</string>
<string name="gesture_key_left">Αριστερό %1$s</string>
<string name="gesture_key_right">Δεξί %1$s</string>
<string name="gesture_key_shift">Shift</string>
</resources>
+50 -4
View File
@@ -538,7 +538,7 @@
<string name="perf_gpuclock_hint">Μόνο Adreno. Λιγότερα κολλήματα, μεγαλύτερη χρήση μπαταρίας και θερμότητα.</string>
<string name="perf_fixes">Διορθώσεις συνεδρίας</string>
<string name="perf_sysmem">Turnip: απόδοση sysmem</string>
<string name="perf_sysmem_hint">Ταχύτερο για το Steam και τα περισσότερα παιχνίδια. Απαιτείται σε Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">Ταχύτερο για το πρόγραμμα-πελάτη Steam· στα παιχνίδια ο οδηγός επιλέγει σε κάθε πέρασμα απόδοση με πλακίδια ή στη μνήμη συστήματος. Απαιτείται σε Adreno 710/720/722.</string>
<string name="perf_xalia">Παράβλεψη του βοηθού xalia του Steam</string>
<string name="perf_fastsync">droiddeck-ntsync (πειραματικό)</string>
<string name="perf_fastsync_hint">Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux, για δοκιμές. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία.</string>
@@ -1924,6 +1924,8 @@
<string name="stores_title">Καταστήματα</string>
<string name="stores_settings">Ρυθμίσεις καταστημάτων</string>
<string name="stores_settings_eyebrow">Ρυθμίσεις</string>
<string name="stores_tabs_arrange">Τακτοποίηση καρτελών</string>
<string name="stores_tabs_arrange_done">Έγινε</string>
<string name="stores_chip_signed_out">(μη συνδεδεμένος)</string>
<string name="stores_tab_store">Κατάστημα</string>
<string name="stores_tab_library">Βιβλιοθήκη</string>
@@ -2150,7 +2152,7 @@
<string name="logo_pos_size">Μέγεθος</string>
<string name="logo_pos_save">Αποθήκευση</string>
<string name="logo_pos_reset">Επαναφορά στην προεπιλογή</string>
<string name="logo_pos_hint">Σχεδιάζεται πάνω από το φόντο του παιχνιδιού, όπως κάνει το Steam. Η ακριβής θέση μπορεί να διαφέρει μερικά pixel.</string>
<string name="logo_pos_hint">Ακριβώς αυτή η θέση μπορεί να διαφέρει κατά μερικά pixel.</string>
<string name="logo_pos_no_logo">Αυτό το παιχνίδι δεν έχει λογότυπο ακόμη. Διάλεξε ένα στο Λογότυπο για να το δεις εδώ.</string>
<string name="logo_pos_failed">Η θέση του λογότυπου δεν αποθηκεύτηκε. Δοκίμασε ξανά.</string>
<string name="art_background">Φόντο</string>
@@ -2679,7 +2681,7 @@
<string name="boot_movie">Βίντεο εκκίνησης</string>
<string name="boot_movie_video">Βίντεο</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">Τα 60 fps φαίνονται πιο ομαλά, αλλά επιβαρύνουν περισσότερο τον επεξεργαστή όσο ξεκινά το Steam. Ένα βίντεο εκκίνησης που επιλέχθηκε στις ρυθμίσεις του Steam έχει προτεραιότητα.</string>
<string name="boot_movie_note">Τα 60 fps φαίνονται πιο ομαλά, αλλά επιβαρύνουν περισσότερο τον επεξεργαστή όσο ξεκινά το Steam. Ένα βίντεο που επιλέχθηκε στις ρυθμίσεις του Steam εμφανίζεται κι εδώ.</string>
<string name="boot_movie_sound">Ήχος</string>
<string name="boot_movie_custom">Δικό σας βίντεο</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV ή WebM. Άλλες μορφές και αρχεία πάνω από %1$d MB μετατρέπονται στη συσκευή σε WebM 720p, έως %2$d δευτερόλεπτα.</string>
@@ -2693,6 +2695,51 @@
<string name="boot_movie_converting_started">Μετατροπή του %1$s για το Steam. Αφήστε το DroidDeck ανοιχτό.</string>
<string name="boot_movie_unsupported">Η συσκευή δεν μπορεί να αποκωδικοποιήσει αυτό το βίντεο.</string>
<string name="boot_movie_import_failed">Δεν ήταν δυνατή η εισαγωγή του βίντεο</string>
<string name="boot_movie_remove_confirm">Αφαίρεση του %1$s;</string>
<string name="boot_movie_remove_confirm_body">Το βίντεο θα διαγραφεί από το DroidDeck και από τον φάκελο βίντεο του Steam.</string>
<string name="boot_movie_tab_installed">Εγκατεστημένα</string>
<string name="boot_movie_tab_import">Εισαγωγή</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Ήχος</string>
<string name="boot_movie_installed">Εγκατεστημένα βίντεο</string>
<string name="boot_movie_browse">Περιήγηση</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Εισαγμένο</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Κατάστημα πόντων</string>
<string name="boot_movie_report">Αναφορά ταινιών εκκίνησης</string>
<string name="boot_movie_report_hint">Αποθηκεύει σε αρχείο κειμένου ό,τι γνωρίζουν το DroidDeck και το Steam για τις ταινίες εκκίνησης, για να το επισυνάψετε σε αναφορά σφάλματος.</string>
<string name="boot_movie_report_save">Αποθήκευση</string>
<string name="boot_movie_report_saved">Αποθηκεύτηκε στο %1$s</string>
<string name="boot_movie_report_failed">Δεν ήταν δυνατή η αποθήκευση της αναφοράς.</string>
<string name="boot_movie_volume">Ένταση</string>
<string name="boot_movie_length">Διάρκεια</string>
<string name="boot_movie_seconds">%1$d δευτ.</string>
<string name="boot_movie_length_hint">Πόσα δευτερόλεπτα της ταινίας αναπαράγονται. Ό,τι είναι μεγαλύτερο κόβεται στο τέλος.</string>
<string name="boot_movie_repo_search">Αναζήτηση στο SteamDeckRepo</string>
<string name="boot_movie_repo_all">Όλες οι συσκευές</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Τάσεις</string>
<string name="boot_movie_repo_downloads">Περισσότερες λήψεις</string>
<string name="boot_movie_repo_likes">Περισσότερα likes</string>
<string name="boot_movie_repo_newest">Νεότερα</string>
<string name="boot_movie_repo_failed">Δεν ήταν δυνατή η σύνδεση με το SteamDeckRepo</string>
<string name="boot_movie_repo_retry">Επανάληψη</string>
<string name="boot_movie_repo_empty">Δεν βρέθηκαν βίντεο</string>
<string name="boot_movie_repo_install">Εγκατάσταση</string>
<string name="boot_movie_repo_use">Χρήση</string>
<string name="boot_movie_in_use">Σε χρήση</string>
<string name="boot_movie_repo_installed">Το %1$s εγκαταστάθηκε</string>
<string name="boot_movie_repo_download_failed">Δεν ήταν δυνατή η λήψη αυτού του βίντεο</string>
<string name="boot_movie_repo_previous">Προηγούμενη</string>
<string name="boot_movie_repo_next">Επόμενη</string>
<string name="boot_movie_repo_page">Σελίδα %1$d</string>
<string name="boot_movie_repo_clear">Εκκαθάριση</string>
<string name="boot_movie_repo_go_to_page">Μετάβαση στη σελίδα</string>
<string name="boot_movie_repo_preview">Προεπισκόπηση</string>
<string name="boot_movie_repo_preview_failed">Η προεπισκόπηση δεν μπόρεσε να αναπαραχθεί</string>
<string name="glasses_experimental">Κάμερες Luma Ultra — Πειραματικό</string>
<string name="glasses_joycons">Παρακολούθηση θέσης Joy-Con</string>
<string name="glasses_joycons_hint">Χρησιμοποιήστε τις κάμερες των έξυπνων γυαλιών για να τοποθετήσετε τα συνδεδεμένα χειριστήρια Joy-Con. Η περιστροφή και οι εντολές κουμπιών προέρχονται από τα χειριστήρια Joy-Con. Η θέση εκτιμάται όταν οι καρποί σας είναι κρυμμένοι. Αντικαθιστά τα χειριστήρια που λειτουργούν με γυμνά χέρια και διακόπτει την παρακολούθηση της κάμερας του τηλεφώνου. Στρέψτε τα χειριστήρια Joy-Con προς τα εμπρός κατά τη σύνδεση ή την επαναφορά του κέντρου παρακολούθησης.</string>
@@ -2866,7 +2913,6 @@
<string name="spatial_hands_wait_stable">Αναμονή για σταθερή παρακολούθηση κεφαλής. Επαναφέρετε το κέντρο παρακολούθησης εάν ζητηθεί</string>
<string name="spatial_old_frame">Η παρατήρηση με κάμερα είναι πολύ παλιά. περιμένοντας ένα φρέσκο ζευγάρι</string>
<string name="spatial_fit_ready">Προσαρμογή %1$.2f px. Μετρήστε την επιλεγμένη απόσταση από την αριστερή κάμερα μέχρι το κέντρο στόχου βαθμονόμησης και, στη συνέχεια, επικυρώστε.</string>
<string name="spatial_opencv_error">Το OpenCV δεν είναι διαθέσιμο: %1$s</string>
<string name="spatial_restored">Αποκαταστάθηκαν οι αποθηκευμένες προβολές %1$d. Υπολογίστε τη βαθμονόμηση για να προσπαθήσετε ξανά ή ξεκινήστε ένα νέο σύνολο για να καταγράψετε περισσότερα.</string>
<string name="spatial_captured">Λήψη %1$d / 24 προβολές. Διατηρήστε σταθερό τον στόχο βαθμονόμησης. μετακινήστε τα έξυπνα γυαλιά για την επόμενη προβολή.</string>
<string name="spatial_capture_corners">Η λήψη απορρίφθηκε: %1$d / 12 απαιτούμενες γωνίες ορατές και στις δύο κάμερες. Εμφάνιση μεγαλύτερου μέρους του στόχου βαθμονόμησης, κρατήστε ακίνητο και μετά δοκιμάστε ξανά.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Gestos</string>
<string name="gesture_settings">Ajustes de gestos táctiles</string>
<string name="gesture_settings_hint">Personaliza los gestos de ratón y teclado. Selecciona Gestos como modo táctil para usarlos.</string>
<string name="gesture_tap">Toque</string>
<string name="gesture_drag">Arrastre con un dedo</string>
<string name="gesture_long_press">Pulsación prolongada</string>
<string name="gesture_double_tap">Doble toque (doble clic izquierdo)</string>
<string name="gesture_two_tap">Toque con dos dedos</string>
<string name="gesture_two_drag">Arrastre con dos dedos</string>
<string name="gesture_two_hold">Pulsación sostenida con dos dedos</string>
<string name="gesture_three_tap">Toque con tres dedos</string>
<string name="gesture_three_drag">Arrastre con tres dedos</string>
<string name="gesture_three_hold">Pulsación sostenida con tres dedos</string>
<string name="gesture_pinch">Pellizcar para ampliar</string>
<string name="gesture_hold_button">Mantener el botón pulsado</string>
<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>
<string name="gesture_left_click">Clic izquierdo</string>
<string name="gesture_right_click">Clic derecho</string>
<string name="gesture_middle_click">Clic central</string>
<string name="gesture_keyboard">Mostrar teclado</string>
<string name="gesture_left_drag">Clic izquierdo y arrastre</string>
<string name="gesture_right_drag">Clic derecho y arrastre</string>
<string name="gesture_middle_pan">Desplazamiento con el botón central</string>
<string name="gesture_middle_pan_inverted">Desplazamiento con el botón central (invertido)</string>
<string name="gesture_arrows">Teclas de dirección</string>
<string name="gesture_arrows_inverted">Teclas de dirección (invertidas)</string>
<string name="gesture_wasd_inverted">WASD (invertido)</string>
<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>
<string name="gesture_no_actions">No hay acciones coincidentes. Prueba con otro nombre de tecla o acción.</string>
<string name="gesture_group_mouse">Ratón</string>
<string name="gesture_group_app">Acciones de la aplicación</string>
<string name="gesture_group_navigation">Navegación</string>
<string name="gesture_group_letters">Letras</string>
<string name="gesture_group_numbers">Números</string>
<string name="gesture_group_function">Teclas de función</string>
<string name="gesture_group_modifiers">Teclas modificadoras</string>
<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>
<string name="gesture_key_numpad">Teclado numérico %1$s</string>
<string name="gesture_key_escape">Escape</string>
<string name="gesture_key_backspace">Retroceso</string>
<string name="gesture_key_enter">Intro</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Espacio</string>
<string name="gesture_key_delete">Supr</string>
<string name="gesture_key_insert">Insertar</string>
<string name="gesture_key_home">Inicio</string>
<string name="gesture_key_end">Fin</string>
<string name="gesture_key_page_up">Re Pág</string>
<string name="gesture_key_page_down">Av Pág</string>
<string name="gesture_key_print_screen">Impr Pant</string>
<string name="gesture_key_up">Flecha arriba</string>
<string name="gesture_key_down">Flecha abajo</string>
<string name="gesture_key_left_arrow">Flecha izquierda</string>
<string name="gesture_key_right_arrow">Flecha derecha</string>
<string name="gesture_key_caps_lock">Bloq Mayús</string>
<string name="gesture_key_num_lock">Bloq Num</string>
<string name="gesture_key_scroll_lock">Bloq Despl</string>
<string name="gesture_key_left">%1$s izquierdo</string>
<string name="gesture_key_right">%1$s derecho</string>
<string name="gesture_key_shift">Mayús</string>
</resources>
+50 -4
View File
@@ -537,7 +537,7 @@
<string name="perf_gpuclock_hint">Solo Adreno. Menos tirones, más consumo de batería y calor.</string>
<string name="perf_fixes">Correcciones de sesión</string>
<string name="perf_sysmem">Turnip: renderizado sysmem</string>
<string name="perf_sysmem_hint">Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722.</string>
<string name="perf_xalia">Omitir el asistente xalia de Steam</string>
<string name="perf_fastsync">droiddeck-ntsync (experimental)</string>
<string name="perf_fastsync_hint">Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.</string>
@@ -1942,6 +1942,8 @@
<string name="stores_title">Tiendas</string>
<string name="stores_settings">Ajustes de las tiendas</string>
<string name="stores_settings_eyebrow">Ajustes</string>
<string name="stores_tabs_arrange">Organizar pestañas</string>
<string name="stores_tabs_arrange_done">Listo</string>
<string name="stores_chip_signed_out">(sin iniciar sesión)</string>
<string name="stores_tab_store">Tienda</string>
<string name="stores_tab_library">Biblioteca</string>
@@ -2165,7 +2167,7 @@
<string name="logo_pos_size">Tamaño</string>
<string name="logo_pos_save">Guardar</string>
<string name="logo_pos_reset">Volver al valor predeterminado</string>
<string name="logo_pos_hint">Se dibuja sobre el fondo del juego, igual que en Steam. La posición exacta puede variar unos píxeles.</string>
<string name="logo_pos_hint">Este punto exacto puede variar unos pocos píxeles.</string>
<string name="logo_pos_no_logo">Este juego aún no tiene logotipo. Elige uno en Logotipo para verlo aquí.</string>
<string name="logo_pos_failed">No se pudo guardar la posición del logotipo. Inténtalo de nuevo.</string>
<string name="art_background">Fondo</string>
@@ -2713,7 +2715,7 @@
<string name="boot_movie">Vídeo de arranque</string>
<string name="boot_movie_video">Vídeo</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un vídeo de inicio elegido en los ajustes de Steam tiene prioridad.</string>
<string name="boot_movie_note">60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un vídeo elegido en los ajustes de Steam también aparece aquí.</string>
<string name="boot_movie_sound">Sonido</string>
<string name="boot_movie_custom">Tu propio vídeo</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV o WebM. Otros formatos y archivos de más de %1$d MB se convierten a WebM 720p en el dispositivo, hasta %2$d segundos.</string>
@@ -2727,6 +2729,51 @@
<string name="boot_movie_converting_started">Convirtiendo %1$s para Steam. Mantén DroidDeck abierto.</string>
<string name="boot_movie_unsupported">Este dispositivo no puede decodificar ese vídeo.</string>
<string name="boot_movie_import_failed">No se pudo importar ese vídeo</string>
<string name="boot_movie_remove_confirm">¿Quitar %1$s?</string>
<string name="boot_movie_remove_confirm_body">Esto elimina el vídeo de DroidDeck y de la carpeta de vídeos de Steam.</string>
<string name="boot_movie_tab_installed">Instalados</string>
<string name="boot_movie_tab_import">Importar</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Sonido</string>
<string name="boot_movie_installed">Vídeos instalados</string>
<string name="boot_movie_browse">Explorar</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Importado</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Tienda de puntos</string>
<string name="boot_movie_report">Informe de películas de arranque</string>
<string name="boot_movie_report_hint">Guarda en un archivo de texto lo que DroidDeck y Steam saben de las películas de arranque, para adjuntarlo a un informe de error.</string>
<string name="boot_movie_report_save">Guardar</string>
<string name="boot_movie_report_saved">Guardado en %1$s</string>
<string name="boot_movie_report_failed">No se pudo guardar el informe.</string>
<string name="boot_movie_volume">Volumen</string>
<string name="boot_movie_length">Duración</string>
<string name="boot_movie_seconds">%1$d s</string>
<string name="boot_movie_length_hint">Cuántos segundos de la película se reproducen. Lo que dure más se corta al final.</string>
<string name="boot_movie_repo_search">Buscar en SteamDeckRepo</string>
<string name="boot_movie_repo_all">Todos los dispositivos</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Tendencias</string>
<string name="boot_movie_repo_downloads">Más descargados</string>
<string name="boot_movie_repo_likes">Más valorados</string>
<string name="boot_movie_repo_newest">Más recientes</string>
<string name="boot_movie_repo_failed">No se pudo acceder a SteamDeckRepo</string>
<string name="boot_movie_repo_retry">Reintentar</string>
<string name="boot_movie_repo_empty">No se encontraron vídeos</string>
<string name="boot_movie_repo_install">Instalar</string>
<string name="boot_movie_repo_use">Usar</string>
<string name="boot_movie_in_use">En uso</string>
<string name="boot_movie_repo_installed">%1$s instalado</string>
<string name="boot_movie_repo_download_failed">No se pudo descargar ese vídeo</string>
<string name="boot_movie_repo_previous">Anterior</string>
<string name="boot_movie_repo_next">Siguiente</string>
<string name="boot_movie_repo_page">Página %1$d</string>
<string name="boot_movie_repo_clear">Borrar</string>
<string name="boot_movie_repo_go_to_page">Ir a la página</string>
<string name="boot_movie_repo_preview">Vista previa</string>
<string name="boot_movie_repo_preview_failed">No se pudo reproducir la vista previa</string>
<string name="glasses_experimental">Cámaras Luma Ultra — Experimental</string>
<string name="glasses_joycons">Seguimiento de posición de los Joy-Con</string>
<string name="glasses_joycons_hint">Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento.</string>
@@ -2900,7 +2947,6 @@
<string name="spatial_hands_wait_stable">Esperando un seguimiento de cabeza estable; restablece el centro de seguimiento si se solicita.</string>
<string name="spatial_old_frame">La observación con cámara es demasiado antigua; esperando un par nuevo</string>
<string name="spatial_fit_ready">Ajustar %1$.2f px. Mida la distancia seleccionada desde la cámara izquierda hasta el centro del objetivo de calibración y luego valide.</string>
<string name="spatial_opencv_error">OpenCV no disponible: %1$s</string>
<string name="spatial_restored">%1$d vistas guardadas restauradas. Calcule la calibración para volver a intentarlo o comience un nuevo conjunto para capturar más.</string>
<string name="spatial_captured">Capturado %1$d / 24 vistas. Mantenga fijo el objetivo de calibración; Mueva las gafas inteligentes para la siguiente vista.</string>
<string name="spatial_capture_corners">Captura rechazada: %1$d / 12 esquinas requeridas visibles en ambas cámaras. Muestre más del objetivo de calibración, quédese quieto y vuelva a intentarlo.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Eleet</string>
<string name="gesture_settings">Kosketuseleiden asetukset</string>
<string name="gesture_settings_hint">Mukauta hiiri- ja näppäimistöeleitä. Ota ne käyttöön valitsemalla kosketustilaksi Eleet.</string>
<string name="gesture_tap">Napautus</string>
<string name="gesture_drag">Veto yhdellä sormella</string>
<string name="gesture_long_press">Pitkä painallus</string>
<string name="gesture_double_tap">Kaksoisnapautus (vasemman painikkeen kaksoisnapsautus)</string>
<string name="gesture_two_tap">Napautus kahdella sormella</string>
<string name="gesture_two_drag">Veto kahdella sormella</string>
<string name="gesture_two_hold">Pito kahdella sormella</string>
<string name="gesture_three_tap">Napautus kolmella sormella</string>
<string name="gesture_three_drag">Veto kolmella sormella</string>
<string name="gesture_three_hold">Pito kolmella sormella</string>
<string name="gesture_pinch">Zoomaa nipistämällä</string>
<string name="gesture_hold_button">Pidä painike painettuna</string>
<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>
<string name="gesture_left_click">Vasen napsautus</string>
<string name="gesture_right_click">Oikea napsautus</string>
<string name="gesture_middle_click">Keskinapsautus</string>
<string name="gesture_keyboard">Näytä näppäimistö</string>
<string name="gesture_left_drag">Vasen napsautus ja veto</string>
<string name="gesture_right_drag">Oikea napsautus ja veto</string>
<string name="gesture_middle_pan">Panorointi hiiren keskipainikkeella</string>
<string name="gesture_middle_pan_inverted">Panorointi hiiren keskipainikkeella (käänteinen)</string>
<string name="gesture_arrows">Nuolinäppäimet</string>
<string name="gesture_arrows_inverted">Nuolinäppäimet (käänteiset)</string>
<string name="gesture_wasd_inverted">WASD (käänteinen)</string>
<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>
<string name="gesture_no_actions">Vastaavia toimintoja ei löytynyt. Kokeile toista näppäimen tai toiminnon nimeä.</string>
<string name="gesture_group_mouse">Hiiri</string>
<string name="gesture_group_app">Sovellustoiminnot</string>
<string name="gesture_group_navigation">Siirtyminen</string>
<string name="gesture_group_letters">Kirjaimet</string>
<string name="gesture_group_numbers">Numerot</string>
<string name="gesture_group_function">Funktionäppäimet</string>
<string name="gesture_group_modifiers">Muunnosnäppäimet</string>
<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>
<string name="gesture_key_numpad">Numeronäppäimistö %1$s</string>
<string name="gesture_key_escape">Esc</string>
<string name="gesture_key_backspace">Askelpalautin</string>
<string name="gesture_key_enter">Enter</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">Delete</string>
<string name="gesture_key_insert">Insert</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">Sivu ylös</string>
<string name="gesture_key_page_down">Sivu alas</string>
<string name="gesture_key_print_screen">Print Screen</string>
<string name="gesture_key_up">Nuoli ylös</string>
<string name="gesture_key_down">Nuoli alas</string>
<string name="gesture_key_left_arrow">Nuoli vasemmalle</string>
<string name="gesture_key_right_arrow">Nuoli oikealle</string>
<string name="gesture_key_caps_lock">Caps Lock</string>
<string name="gesture_key_num_lock">Num Lock</string>
<string name="gesture_key_scroll_lock">Scroll Lock</string>
<string name="gesture_key_left">Vasen %1$s</string>
<string name="gesture_key_right">Oikea %1$s</string>
<string name="gesture_key_shift">Vaihto</string>
</resources>
+50 -4
View File
@@ -538,7 +538,7 @@
<string name="perf_gpuclock_hint">Vain Adreno. Vähemmän nykäyksiä, enemmän akun kulutusta ja lämpöä.</string>
<string name="perf_fixes">Istunnon korjaukset</string>
<string name="perf_sysmem">Turnip: sysmem-renderöinti</string>
<string name="perf_sysmem_hint">Nopeampi Steamille ja useimmille peleille. Pakollinen Adreno 710/720/722:lla.</string>
<string name="perf_sysmem_hint">Nopeampi Steam-asiakasohjelmalle; peleissä ajuri valitsee jokaiselle vaiheelle ruuduittaisen tai järjestelmämuistiin tehtävän renderöinnin. Pakollinen Adreno 710/720/722:lla.</string>
<string name="perf_xalia">Ohita Steamin xalia-avustaja</string>
<string name="perf_fastsync">droiddeck-ntsync (kokeellinen)</string>
<string name="perf_fastsync_hint">Käyttäjätilainen korvike Linuxin ntsync-ajurille, testausta varten. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa.</string>
@@ -1924,6 +1924,8 @@
<string name="stores_title">Kaupat</string>
<string name="stores_settings">Kauppojen asetukset</string>
<string name="stores_settings_eyebrow">Asetukset</string>
<string name="stores_tabs_arrange">Järjestä välilehdet</string>
<string name="stores_tabs_arrange_done">Valmis</string>
<string name="stores_chip_signed_out">(ei kirjautunut)</string>
<string name="stores_tab_store">Kauppa</string>
<string name="stores_tab_library">Kirjasto</string>
@@ -2150,7 +2152,7 @@
<string name="logo_pos_size">Koko</string>
<string name="logo_pos_save">Tallenna</string>
<string name="logo_pos_reset">Takaisin oletukseen</string>
<string name="logo_pos_hint">Piirretään pelin taustan päälle, kuten Steam tekee. Tarkka sijainti voi poiketa muutaman pikselin.</string>
<string name="logo_pos_hint">Tämä tarkka kohta voi vaihdella muutamalla pikselillä.</string>
<string name="logo_pos_no_logo">Tällä pelillä ei ole vielä logoa. Valitse sellainen kohdasta Logo, niin näet sen tässä.</string>
<string name="logo_pos_failed">Logon sijaintia ei voitu tallentaa. Yritä uudelleen.</string>
<string name="art_background">Tausta</string>
@@ -2679,7 +2681,7 @@
<string name="boot_movie">Käynnistysvideo</string>
<string name="boot_movie_video">Video</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">60 fps näyttää sulavammalta, mutta kuormittaa suoritinta enemmän Steamin käynnistyessä. Steamin omista asetuksista valittu käynnistysvideo on etusijalla.</string>
<string name="boot_movie_note">60 fps näyttää sulavammalta, mutta kuormittaa suoritinta enemmän Steamin käynnistyessä. Steamin omista asetuksista valittu video näkyy myös täällä.</string>
<string name="boot_movie_sound">Ääni</string>
<string name="boot_movie_custom">Oma video</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV tai WebM. Muut muodot ja yli %1$d Mt:n tiedostot muunnetaan laitteella 720p-WebM-muotoon, enintään %2$d sekuntia.</string>
@@ -2693,6 +2695,51 @@
<string name="boot_movie_converting_started">Muunnetaan %1$s Steamille. Pidä DroidDeck auki.</string>
<string name="boot_movie_unsupported">Laite ei pysty purkamaan videota.</string>
<string name="boot_movie_import_failed">Videon tuonti epäonnistui</string>
<string name="boot_movie_remove_confirm">Poistetaanko %1$s?</string>
<string name="boot_movie_remove_confirm_body">Tämä poistaa videon DroidDeckistä ja Steamin videokansiosta.</string>
<string name="boot_movie_tab_installed">Asennetut</string>
<string name="boot_movie_tab_import">Tuo</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Ääni</string>
<string name="boot_movie_installed">Asennetut videot</string>
<string name="boot_movie_browse">Selaa</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Tuotu</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Pisteputiikki</string>
<string name="boot_movie_report">Käynnistyselokuvaraportti</string>
<string name="boot_movie_report_hint">Tallentaa tekstitiedostoon, mitä DroidDeck ja Steam tietävät käynnistyselokuvista, liitettäväksi virheilmoitukseen.</string>
<string name="boot_movie_report_save">Tallenna</string>
<string name="boot_movie_report_saved">Tallennettu: %1$s</string>
<string name="boot_movie_report_failed">Raportin tallennus epäonnistui.</string>
<string name="boot_movie_volume">Äänenvoimakkuus</string>
<string name="boot_movie_length">Pituus</string>
<string name="boot_movie_seconds">%1$d s</string>
<string name="boot_movie_length_hint">Kuinka monta sekuntia elokuvasta toistetaan. Sitä pidempi osa leikataan pois lopusta.</string>
<string name="boot_movie_repo_search">Hae SteamDeckReposta</string>
<string name="boot_movie_repo_all">Kaikki laitteet</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Trendaavat</string>
<string name="boot_movie_repo_downloads">Ladatuimmat</string>
<string name="boot_movie_repo_likes">Tykätyimmät</string>
<string name="boot_movie_repo_newest">Uusimmat</string>
<string name="boot_movie_repo_failed">SteamDeckRepoon ei saatu yhteyttä</string>
<string name="boot_movie_repo_retry">Yritä uudelleen</string>
<string name="boot_movie_repo_empty">Videoita ei löytynyt</string>
<string name="boot_movie_repo_install">Asenna</string>
<string name="boot_movie_repo_use">Käytä</string>
<string name="boot_movie_in_use">Käytössä</string>
<string name="boot_movie_repo_installed">%1$s asennettu</string>
<string name="boot_movie_repo_download_failed">Videota ei voitu ladata</string>
<string name="boot_movie_repo_previous">Edellinen</string>
<string name="boot_movie_repo_next">Seuraava</string>
<string name="boot_movie_repo_page">Sivu %1$d</string>
<string name="boot_movie_repo_clear">Tyhjennä</string>
<string name="boot_movie_repo_go_to_page">Siirry sivulle</string>
<string name="boot_movie_repo_preview">Esikatselu</string>
<string name="boot_movie_repo_preview_failed">Esikatselua ei voitu toistaa</string>
<string name="glasses_experimental">Luma Ultra -kamerat — Kokeellinen</string>
<string name="glasses_joycons">Joy-Con-paikan seuranta</string>
<string name="glasses_joycons_hint">Käytä älylasien kameroita pariliitettyjen Joy-Con-ohjainten asemointiin. Kierto- ja painikesyötteet tulevat Joy-Con-ohjaimista. Sijainti arvioidaan, kun ranteesi ovat piilossa. Korvaa paljain käsin käytettävät ohjaimet ja keskeyttää puhelimen kameran seurannan. Osoita Joy-Con-ohjaimet eteenpäin, kun yhdistät tai nollaat seurantakeskuksen.</string>
@@ -2866,7 +2913,6 @@
<string name="spatial_hands_wait_stable">Odotetaan vakaata pään seurantaa; nollaa seurantakeskus pyydettäessä.</string>
<string name="spatial_old_frame">Kamerahavainto on liian vanha; uutta paria odotellessa</string>
<string name="spatial_fit_ready">Sovita %1$.2f px. Mittaa valitsemasi etäisyys vasemmasta kamerasta kalibrointikohteen keskustaan ​​ja vahvista sitten.</string>
<string name="spatial_opencv_error">OpenCV ei saatavilla: %1$s</string>
<string name="spatial_restored">%1$d tallennettua näkymää palautettu. Laske kalibrointi yrittääksesi uudelleen tai aloita uusi sarja, jos haluat ottaa lisää.</string>
<string name="spatial_captured">Kuvattu %1$d / 24 katselukertaa. Pidä kalibrointitavoite kiinteänä; siirrä älylaseja seuraavaa näkymää varten.</string>
<string name="spatial_capture_corners">Kuvaus hylätty: %1$d / 12 vaadittua kulmaa näkyvissä molemmissa kameroissa. Näytä lisää kalibrointikohdetta, pidä paikallaan ja yritä sitten uudelleen.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Gestes</string>
<string name="gesture_settings">Paramètres des gestes tactiles</string>
<string name="gesture_settings_hint">Personnalisez les gestes de souris et de clavier. Sélectionnez Gestes comme mode tactile pour les utiliser.</string>
<string name="gesture_tap">Appui</string>
<string name="gesture_drag">Glissement à un doigt</string>
<string name="gesture_long_press">Appui prolongé</string>
<string name="gesture_double_tap">Double appui (double clic gauche)</string>
<string name="gesture_two_tap">Appui à deux doigts</string>
<string name="gesture_two_drag">Glissement à deux doigts</string>
<string name="gesture_two_hold">Maintien à deux doigts</string>
<string name="gesture_three_tap">Appui à trois doigts</string>
<string name="gesture_three_drag">Glissement à trois doigts</string>
<string name="gesture_three_hold">Maintien à trois doigts</string>
<string name="gesture_pinch">Pincer pour zoomer</string>
<string name="gesture_hold_button">Maintenir le bouton enfoncé</string>
<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>
<string name="gesture_left_click">Clic gauche</string>
<string name="gesture_right_click">Clic droit</string>
<string name="gesture_middle_click">Clic du milieu</string>
<string name="gesture_keyboard">Afficher le clavier</string>
<string name="gesture_left_drag">Clic gauche et glissement</string>
<string name="gesture_right_drag">Clic droit et glissement</string>
<string name="gesture_middle_pan">Déplacement avec le bouton du milieu</string>
<string name="gesture_middle_pan_inverted">Déplacement avec le bouton du milieu (inversé)</string>
<string name="gesture_arrows">Touches fléchées</string>
<string name="gesture_arrows_inverted">Touches fléchées (inversées)</string>
<string name="gesture_wasd_inverted">WASD (inversé)</string>
<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>
<string name="gesture_no_actions">Aucune action correspondante. Essayez un autre nom de touche ou d\'action.</string>
<string name="gesture_group_mouse">Souris</string>
<string name="gesture_group_app">Actions de l\'application</string>
<string name="gesture_group_navigation">Navigation</string>
<string name="gesture_group_letters">Lettres</string>
<string name="gesture_group_numbers">Chiffres</string>
<string name="gesture_group_function">Touches de fonction</string>
<string name="gesture_group_modifiers">Touches de modification</string>
<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>
<string name="gesture_key_numpad">Pavé numérique %1$s</string>
<string name="gesture_key_escape">Échap</string>
<string name="gesture_key_backspace">Retour arrière</string>
<string name="gesture_key_enter">Entrée</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Espace</string>
<string name="gesture_key_delete">Suppr</string>
<string name="gesture_key_insert">Inser</string>
<string name="gesture_key_home">Début</string>
<string name="gesture_key_end">Fin</string>
<string name="gesture_key_page_up">Page précédente</string>
<string name="gesture_key_page_down">Page suivante</string>
<string name="gesture_key_print_screen">Impr. écran</string>
<string name="gesture_key_up">Flèche haut</string>
<string name="gesture_key_down">Flèche bas</string>
<string name="gesture_key_left_arrow">Flèche gauche</string>
<string name="gesture_key_right_arrow">Flèche droite</string>
<string name="gesture_key_caps_lock">Verr. maj.</string>
<string name="gesture_key_num_lock">Verr. num.</string>
<string name="gesture_key_scroll_lock">Arrêt défil.</string>
<string name="gesture_key_left">%1$s gauche</string>
<string name="gesture_key_right">%1$s droite</string>
<string name="gesture_key_shift">Maj</string>
</resources>
+50 -4
View File
@@ -537,7 +537,7 @@
<string name="perf_gpuclock_hint">Uniquement pour Adreno. Moins de saccades, mais plus de consommation d\'énergie et de chaleur.</string>
<string name="perf_fixes">Correctifs de session</string>
<string name="perf_sysmem">Turnip : rendu sysmem</string>
<string name="perf_sysmem_hint">Steam et la plupart des jeux seront plus rapides. Requis pour Adreno 710/720/722.</string>
<string name="perf_sysmem_hint">Le client Steam sera plus rapide ; en jeu, le pilote choisit à chaque passe entre rendu par tuiles et rendu en mémoire système. Requis pour Adreno 710/720/722.</string>
<string name="perf_xalia">Ignorer l\'assistant xalia de Steam</string>
<string name="perf_fastsync">droiddeck-ntsync (expérimental)</string>
<string name="perf_fastsync_hint">Un substitut en espace utilisateur du pilote Linux ntsync, pour les tests. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session.</string>
@@ -1942,6 +1942,8 @@
<string name="stores_title">Boutiques</string>
<string name="stores_settings">Paramètres des boutiques</string>
<string name="stores_settings_eyebrow">Paramètres</string>
<string name="stores_tabs_arrange">Organiser les onglets</string>
<string name="stores_tabs_arrange_done">Terminé</string>
<string name="stores_chip_signed_out">(non connecté)</string>
<string name="stores_tab_store">Boutique</string>
<string name="stores_tab_library">Bibliothèque</string>
@@ -2165,7 +2167,7 @@
<string name="logo_pos_size">Taille</string>
<string name="logo_pos_save">Enregistrer</string>
<string name="logo_pos_reset">Rétablir par défaut</string>
<string name="logo_pos_hint">Dessiné sur l\'arrière-plan du jeu, comme le fait Steam. La position exacte peut varier de quelques pixels.</string>
<string name="logo_pos_hint">Cet emplacement exact peut varier de quelques pixels.</string>
<string name="logo_pos_no_logo">Ce jeu n\'a pas encore de logo. Choisissez-en un dans Logo pour le voir ici.</string>
<string name="logo_pos_failed">Impossible d\'enregistrer la position du logo. Réessayez.</string>
<string name="art_background">Arrière-plan</string>
@@ -2713,7 +2715,7 @@
<string name="boot_movie">Vidéo de démarrage</string>
<string name="boot_movie_video">Vidéo</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d i/s</string>
<string name="boot_movie_note">60 i/s est plus fluide mais sollicite davantage le processeur pendant le démarrage de Steam. Une vidéo de démarrage choisie dans les paramètres de Steam est prioritaire.</string>
<string name="boot_movie_note">60 i/s est plus fluide mais sollicite davantage le processeur pendant le démarrage de Steam. Une vidéo choisie dans les paramètres de Steam apparaît aussi ici.</string>
<string name="boot_movie_sound">Son</string>
<string name="boot_movie_custom">Votre propre vidéo</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV ou WebM. Les autres formats et les fichiers de plus de %1$d Mo sont convertis en WebM 720p sur l\'appareil, jusqu\'à %2$d secondes.</string>
@@ -2727,6 +2729,51 @@
<string name="boot_movie_converting_started">Conversion de %1$s pour Steam. Gardez DroidDeck ouvert.</string>
<string name="boot_movie_unsupported">Cet appareil ne peut pas décoder cette vidéo.</string>
<string name="boot_movie_import_failed">Impossible d\'importer cette vidéo</string>
<string name="boot_movie_remove_confirm">Supprimer %1$s ?</string>
<string name="boot_movie_remove_confirm_body">Cela supprime la vidéo de DroidDeck et du dossier de vidéos de Steam.</string>
<string name="boot_movie_tab_installed">Installées</string>
<string name="boot_movie_tab_import">Importer</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Son</string>
<string name="boot_movie_installed">Vidéos installées</string>
<string name="boot_movie_browse">Parcourir</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Importée</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Boutique de points</string>
<string name="boot_movie_report">Rapport sur les vidéos de démarrage</string>
<string name="boot_movie_report_hint">Enregistre dans un fichier texte ce que DroidDeck et Steam savent des vidéos de démarrage, à joindre à un rapport de bogue.</string>
<string name="boot_movie_report_save">Enregistrer</string>
<string name="boot_movie_report_saved">Enregistré dans %1$s</string>
<string name="boot_movie_report_failed">Impossible d\'enregistrer le rapport.</string>
<string name="boot_movie_volume">Volume</string>
<string name="boot_movie_length">Durée</string>
<string name="boot_movie_seconds">%1$d s</string>
<string name="boot_movie_length_hint">Nombre de secondes du film qui sont lues. Tout ce qui dépasse est coupé à la fin.</string>
<string name="boot_movie_repo_search">Rechercher dans SteamDeckRepo</string>
<string name="boot_movie_repo_all">Tous les appareils</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Tendances</string>
<string name="boot_movie_repo_downloads">Les plus téléchargées</string>
<string name="boot_movie_repo_likes">Les plus aimées</string>
<string name="boot_movie_repo_newest">Les plus récentes</string>
<string name="boot_movie_repo_failed">Impossible de joindre SteamDeckRepo</string>
<string name="boot_movie_repo_retry">Réessayer</string>
<string name="boot_movie_repo_empty">Aucune vidéo trouvée</string>
<string name="boot_movie_repo_install">Installer</string>
<string name="boot_movie_repo_use">Utiliser</string>
<string name="boot_movie_in_use">Utilisé</string>
<string name="boot_movie_repo_installed">%1$s installée</string>
<string name="boot_movie_repo_download_failed">Impossible de télécharger cette vidéo</string>
<string name="boot_movie_repo_previous">Précédent</string>
<string name="boot_movie_repo_next">Suivant</string>
<string name="boot_movie_repo_page">Page %1$d</string>
<string name="boot_movie_repo_clear">Effacer</string>
<string name="boot_movie_repo_go_to_page">Aller à la page</string>
<string name="boot_movie_repo_preview">Aperçu</string>
<string name="boot_movie_repo_preview_failed">Impossible de lire cet aperçu</string>
<string name="glasses_experimental">Caméras Luma Ultra — Expérimental</string>
<string name="glasses_joycons">Suivi de la position des Joy-Con</string>
<string name="glasses_joycons_hint">Utilisez les caméras des lunettes connectées pour positionner vos manettes Joy-Con appariées. La rotation et les entrées des boutons proviennent des manettes Joy-Con. La position est estimée lorsque vos poignets sont cachés. Remplace les manettes utilisées à mains nues et interrompt le suivi par la caméra du téléphone. Pointez les manettes Joy-Con vers l\'avant lors de la connexion ou de la réinitialisation du centre de suivi.</string>
@@ -2900,7 +2947,6 @@
<string name="spatial_hands_wait_stable">Attente d\'un suivi de tête stable ; réinitialisation du centre de suivi si demandé.</string>
<string name="spatial_old_frame">L\'observation par caméra est trop ancienne ; en attendant une nouvelle paire</string>
<string name="spatial_fit_ready">Ajuster %1$.2f px. Mesurez la distance sélectionnée entre la caméra gauche et le centre de la cible de calibrage, puis validez.</string>
<string name="spatial_opencv_error">OpenCV indisponible : %1$s</string>
<string name="spatial_restored">%1$d vues enregistrées restaurées. Calculez l’étalonnage pour réessayer ou démarrez un nouvel ensemble pour en capturer davantage.</string>
<string name="spatial_captured">Capturé %1$d / 24 vues. Gardez la cible d\'étalonnage fixe ; déplacez les lunettes intelligentes pour la vue suivante.</string>
<string name="spatial_capture_corners">Capture rejetée : %1$d / 12 coins requis visibles dans les deux caméras. Affichez davantage la cible d\'étalonnage, restez immobile, puis réessayez.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">जेस्चर</string>
<string name="gesture_settings">टच जेस्चर सेटिंग</string>
<string name="gesture_settings_hint">माउस और कीबोर्ड जेस्चर बदलें। इस्तेमाल करने के लिए टच मोड में जेस्चर चुनें।</string>
<string name="gesture_tap">टैप</string>
<string name="gesture_drag">एक उँगली से खींचना</string>
<string name="gesture_long_press">देर तक दबाना</string>
<string name="gesture_double_tap">दो बार टैप (दो बार बायाँ क्लिक)</string>
<string name="gesture_two_tap">दो उँगलियों से टैप</string>
<string name="gesture_two_drag">दो उँगलियों से खींचना</string>
<string name="gesture_two_hold">दो उँगलियों से दबाए रखना</string>
<string name="gesture_three_tap">तीन उँगलियों से टैप</string>
<string name="gesture_three_drag">तीन उँगलियों से खींचना</string>
<string name="gesture_three_hold">तीन उँगलियों से दबाए रखना</string>
<string name="gesture_pinch">पिंच करके ज़ूम करें</string>
<string name="gesture_hold_button">बटन दबाए रखें</string>
<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>
<string name="gesture_left_click">बायाँ क्लिक</string>
<string name="gesture_right_click">दायाँ क्लिक</string>
<string name="gesture_middle_click">बीच का क्लिक</string>
<string name="gesture_keyboard">कीबोर्ड दिखाएं</string>
<string name="gesture_left_drag">बायाँ क्लिक करके खींचना</string>
<string name="gesture_right_drag">दायाँ क्लिक करके खींचना</string>
<string name="gesture_middle_pan">माउस के बीच वाले बटन से दृश्य खिसकाना</string>
<string name="gesture_middle_pan_inverted">माउस के बीच वाले बटन से दृश्य खिसकाना (उल्टा)</string>
<string name="gesture_arrows">तीर वाली कुंजियाँ</string>
<string name="gesture_arrows_inverted">तीर वाली कुंजियाँ (उल्टी)</string>
<string name="gesture_wasd_inverted">WASD (उल्टा)</string>
<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>
<string name="gesture_no_actions">कोई मिलती-जुलती कार्रवाई नहीं मिली। किसी दूसरी कुंजी या कार्रवाई का नाम आज़माएँ।</string>
<string name="gesture_group_mouse">माउस</string>
<string name="gesture_group_app">ऐप की कार्रवाइयाँ</string>
<string name="gesture_group_navigation">नेविगेशन</string>
<string name="gesture_group_letters">अक्षर</string>
<string name="gesture_group_numbers">अंक</string>
<string name="gesture_group_function">फ़ंक्शन कुंजियाँ</string>
<string name="gesture_group_modifiers">मॉडिफ़ायर कुंजियाँ</string>
<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>
<string name="gesture_key_numpad">नंबर पैड %1$s</string>
<string name="gesture_key_escape">एस्केप</string>
<string name="gesture_key_backspace">बैकस्पेस</string>
<string name="gesture_key_enter">एंटर</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">डिलीट</string>
<string name="gesture_key_insert">इंसर्ट</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">पेज अप</string>
<string name="gesture_key_page_down">पेज डाउन</string>
<string name="gesture_key_print_screen">प्रिंट स्क्रीन</string>
<string name="gesture_key_up">ऊपर का तीर</string>
<string name="gesture_key_down">नीचे का तीर</string>
<string name="gesture_key_left_arrow">बायाँ तीर</string>
<string name="gesture_key_right_arrow">दायाँ तीर</string>
<string name="gesture_key_caps_lock">कैप्स लॉक</string>
<string name="gesture_key_num_lock">नम लॉक</string>
<string name="gesture_key_scroll_lock">स्क्रॉल लॉक</string>
<string name="gesture_key_left">बायाँ %1$s</string>
<string name="gesture_key_right">दायाँ %1$s</string>
<string name="gesture_key_shift">शिफ़्ट</string>
</resources>
+50 -4
View File
@@ -538,7 +538,7 @@
<string name="perf_gpuclock_hint">सिर्फ़ Adreno के लिए। कम हिचकोले, लेकिन ज़्यादा बैटरी खर्च और गर्मी।</string>
<string name="perf_fixes">सेशन फ़िक्स</string>
<string name="perf_sysmem">Turnip: sysmem रेंडरिंग</string>
<string name="perf_sysmem_hint">Steam और ज़्यादातर गेम के लिए तेज़। Adreno 710/720/722 पर ज़रूरी।</string>
<string name="perf_sysmem_hint">Steam क्लाइंट के लिए तेज़; गेम में ड्राइवर हर पास के लिए टाइल या सिस्टम मेमोरी रेंडरिंग चुनता है। Adreno 710/720/722 पर ज़रूरी।</string>
<string name="perf_xalia">Steam के xalia हेल्पर को छोड़ें</string>
<string name="perf_fastsync">droiddeck-ntsync (एक्सपेरिमेंटल)</string>
<string name="perf_fastsync_hint">जांच के लिए Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है।</string>
@@ -1924,6 +1924,8 @@
<string name="stores_title">स्टोर</string>
<string name="stores_settings">स्टोर सेटिंग</string>
<string name="stores_settings_eyebrow">सेटिंग</string>
<string name="stores_tabs_arrange">टैब व्यवस्थित करें</string>
<string name="stores_tabs_arrange_done">हो गया</string>
<string name="stores_chip_signed_out">(साइन इन नहीं है)</string>
<string name="stores_tab_store">स्टोर</string>
<string name="stores_tab_library">लाइब्रेरी</string>
@@ -2150,7 +2152,7 @@
<string name="logo_pos_size">आकार</string>
<string name="logo_pos_save">सहेजें</string>
<string name="logo_pos_reset">डिफ़ॉल्ट पर लौटाएँ</string>
<string name="logo_pos_hint">गेम की पृष्ठभूमि के ऊपर बनता है, जैसे Steam करता है। सटीक स्थिति कुछ पिक्सेल अलग हो सकती है।</string>
<string name="logo_pos_hint">यह सटीक जगह कुछ पिक्सल तक अलग हो सकती है।</string>
<string name="logo_pos_no_logo">इस गेम का लोगो अभी नहीं है। यहाँ देखने के लिए लोगो में से कोई चुनें।</string>
<string name="logo_pos_failed">लोगो की स्थिति सहेजी नहीं जा सकी। फिर कोशिश करें।</string>
<string name="art_background">बैकग्राउंड</string>
@@ -2679,7 +2681,7 @@
<string name="boot_movie">बूट वीडियो</string>
<string name="boot_movie_video">वीडियो</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">60 fps ज़्यादा स्मूथ दिखता है, लेकिन Steam शुरू होते समय CPU ज़्यादा इस्तेमाल करता है। Steam की अपनी सेटिंग्स में चुना गया स्टार्टअप वीडियो प्राथमिकता लेता है।</string>
<string name="boot_movie_note">60 fps ज़्यादा स्मूथ दिखता है, लेकिन Steam शुरू होते समय CPU ज़्यादा इस्तेमाल करता है। Steam की अपनी सेटिंग्स में चुना गया वीडियो यहाँ भी दिखता है।</string>
<string name="boot_movie_sound">आवाज़</string>
<string name="boot_movie_custom">अपना वीडियो</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV या WebM. दूसरे फ़ॉर्मैट और %1$d MB से बड़ी फ़ाइलें डिवाइस पर 720p WebM में बदली जाती हैं, %2$d सेकंड तक।</string>
@@ -2693,6 +2695,51 @@
<string name="boot_movie_converting_started">%1$s को Steam के लिए बदला जा रहा है। DroidDeck खुला रखें।</string>
<string name="boot_movie_unsupported">यह डिवाइस उस वीडियो को डिकोड नहीं कर सकता।</string>
<string name="boot_movie_import_failed">वीडियो इंपोर्ट नहीं हो सका</string>
<string name="boot_movie_remove_confirm">%1$s हटाएं?</string>
<string name="boot_movie_remove_confirm_body">इससे वीडियो DroidDeck और Steam के वीडियो फ़ोल्डर से मिट जाएगा।</string>
<string name="boot_movie_tab_installed">इंस्टॉल किए गए</string>
<string name="boot_movie_tab_import">इंपोर्ट करें</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">आवाज़</string>
<string name="boot_movie_installed">इंस्टॉल किए गए वीडियो</string>
<string name="boot_movie_browse">ब्राउज़ करें</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">इंपोर्ट किया गया</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">पॉइंट्स शॉप</string>
<string name="boot_movie_report">बूट वीडियो रिपोर्ट</string>
<string name="boot_movie_report_hint">बूट वीडियो के बारे में DroidDeck और Steam की जानकारी एक टेक्स्ट फ़ाइल में सहेजता है, जिसे आप बग रिपोर्ट के साथ जोड़ सकते हैं।</string>
<string name="boot_movie_report_save">सहेजें</string>
<string name="boot_movie_report_saved">%1$s में सहेजा गया</string>
<string name="boot_movie_report_failed">रिपोर्ट सहेजी नहीं जा सकी।</string>
<string name="boot_movie_volume">वॉल्यूम</string>
<string name="boot_movie_length">अवधि</string>
<string name="boot_movie_seconds">%1$d सेक</string>
<string name="boot_movie_length_hint">वीडियो के कितने सेकंड चलेंगे। इससे लंबा हिस्सा अंत में काट दिया जाता है।</string>
<string name="boot_movie_repo_search">SteamDeckRepo में खोजें</string>
<string name="boot_movie_repo_all">सभी डिवाइस</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">ट्रेंडिंग</string>
<string name="boot_movie_repo_downloads">सबसे ज़्यादा डाउनलोड किए गए</string>
<string name="boot_movie_repo_likes">सबसे ज़्यादा पसंद किए गए</string>
<string name="boot_movie_repo_newest">सबसे नए</string>
<string name="boot_movie_repo_failed">SteamDeckRepo से कनेक्ट नहीं हो सका</string>
<string name="boot_movie_repo_retry">फिर कोशिश करें</string>
<string name="boot_movie_repo_empty">कोई वीडियो नहीं मिला</string>
<string name="boot_movie_repo_install">इंस्टॉल करें</string>
<string name="boot_movie_repo_use">इस्तेमाल करें</string>
<string name="boot_movie_in_use">इस्तेमाल में</string>
<string name="boot_movie_repo_installed">%1$s इंस्टॉल हो गया</string>
<string name="boot_movie_repo_download_failed">वह वीडियो डाउनलोड नहीं हो सका</string>
<string name="boot_movie_repo_previous">पिछला</string>
<string name="boot_movie_repo_next">अगला</string>
<string name="boot_movie_repo_page">पेज %1$d</string>
<string name="boot_movie_repo_clear">साफ़ करें</string>
<string name="boot_movie_repo_go_to_page">पेज पर जाएँ</string>
<string name="boot_movie_repo_preview">पूर्वावलोकन</string>
<string name="boot_movie_repo_preview_failed">यह पूर्वावलोकन चलाया नहीं जा सका</string>
<string name="glasses_experimental">Luma Ultra कैमरे — एक्सपेरिमेंटल</string>
<string name="glasses_joycons">Joy-Con पोजीशन ट्रैकिंग</string>
<string name="glasses_joycons_hint">अपने पेयर्ड Joy-Con कंट्रोलर्स को सही जगह पर लगाने के लिए स्मार्ट ग्लास के कैमरे का इस्तेमाल करें। रोटेशन और बटन इनपुट Joy-Con कंट्रोलर्स से आते हैं। जब आपकी कलाइयां छिपी होती हैं, तो पोजीशन का अंदाज़ा लगाया जाता है। नंगे हाथों से चलने वाले कंट्रोलर्स को बदलता है और फ़ोन-कैमरा ट्रैकिंग को पॉज़ करता है। ट्रैकिंग सेंटर को कनेक्ट या रीसेट करते समय Joy-Con कंट्रोलर्स को आगे की ओर पॉइंट करें।</string>
@@ -2866,7 +2913,6 @@
<string name="spatial_hands_wait_stable">स्थिर हेड ट्रैकिंग की प्रतीक्षा कर रहा है; अनुरोध किए जाने पर ट्रैकिंग सेंटर को रीसेट करें</string>
<string name="spatial_old_frame">कैमरा अवलोकन बहुत पुराना है; एक नई जोड़ी का इंतजार है</string>
<string name="spatial_fit_ready">फ़िट %1$.2f पीएक्स। बाएं कैमरे से अंशांकन लक्ष्य केंद्र तक अपनी चयनित दूरी को मापें, फिर सत्यापित करें।</string>
<string name="spatial_opencv_error">OpenCV अनुपलब्ध: %1$s</string>
<string name="spatial_restored">%1$d सहेजे गए दृश्य पुनर्स्थापित किए गए। पुनः प्रयास करने के लिए अंशांकन की गणना करें, या अधिक कैप्चर करने के लिए एक नया सेट प्रारंभ करें।</string>
<string name="spatial_captured">कैप्चर किया गया %1$d / 24 बार देखा गया। अंशांकन लक्ष्य निश्चित रखें; अगले दृश्य के लिए स्मार्ट चश्मा हटाएँ।</string>
<string name="spatial_capture_corners">कैप्चर अस्वीकृत: %1$d / दोनों कैमरों में 12 आवश्यक कोने दिखाई दे रहे हैं। अंशांकन लक्ष्य को और अधिक दिखाएं, स्थिर रखें, फिर पुनः प्रयास करें।</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Gesztusok</string>
<string name="gesture_settings">Érintési gesztusok beállításai</string>
<string name="gesture_settings_hint">Szabd testre az egér- és billentyűzetgesztusokat. Használatukhoz válaszd a Gesztusok érintési módot.</string>
<string name="gesture_tap">Koppintás</string>
<string name="gesture_drag">Húzás egy ujjal</string>
<string name="gesture_long_press">Hosszú nyomás</string>
<string name="gesture_double_tap">Dupla koppintás (dupla bal kattintás)</string>
<string name="gesture_two_tap">Koppintás két ujjal</string>
<string name="gesture_two_drag">Húzás két ujjal</string>
<string name="gesture_two_hold">Nyomva tartás két ujjal</string>
<string name="gesture_three_tap">Koppintás három ujjal</string>
<string name="gesture_three_drag">Húzás három ujjal</string>
<string name="gesture_three_hold">Nyomva tartás három ujjal</string>
<string name="gesture_pinch">Csippentéses nagyítás</string>
<string name="gesture_hold_button">Gomb nyomva tartása</string>
<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>
<string name="gesture_left_click">Bal kattintás</string>
<string name="gesture_right_click">Jobb kattintás</string>
<string name="gesture_middle_click">Középső kattintás</string>
<string name="gesture_keyboard">Billentyűzet megjelenítése</string>
<string name="gesture_left_drag">Bal kattintás és húzás</string>
<string name="gesture_right_drag">Jobb kattintás és húzás</string>
<string name="gesture_middle_pan">Pásztázás középső egérgombbal</string>
<string name="gesture_middle_pan_inverted">Pásztázás középső egérgombbal (fordított)</string>
<string name="gesture_arrows">Nyílbillentyűk</string>
<string name="gesture_arrows_inverted">Nyílbillentyűk (fordított)</string>
<string name="gesture_wasd_inverted">WASD (fordított)</string>
<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>
<string name="gesture_no_actions">Nincs megfelelő művelet. Próbálj másik billentyű- vagy műveletnevet.</string>
<string name="gesture_group_mouse">Egér</string>
<string name="gesture_group_app">Alkalmazásműveletek</string>
<string name="gesture_group_navigation">Navigáció</string>
<string name="gesture_group_letters">Betűk</string>
<string name="gesture_group_numbers">Számok</string>
<string name="gesture_group_function">Funkcióbillentyűk</string>
<string name="gesture_group_modifiers">Módosítóbillentyűk</string>
<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>
<string name="gesture_key_numpad">Numerikus billentyűzet %1$s</string>
<string name="gesture_key_escape">Escape</string>
<string name="gesture_key_backspace">Backspace</string>
<string name="gesture_key_enter">Enter</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">Delete</string>
<string name="gesture_key_insert">Insert</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">Page Up</string>
<string name="gesture_key_page_down">Page Down</string>
<string name="gesture_key_print_screen">Print Screen</string>
<string name="gesture_key_up">Fel nyíl</string>
<string name="gesture_key_down">Le nyíl</string>
<string name="gesture_key_left_arrow">Balra nyíl</string>
<string name="gesture_key_right_arrow">Jobbra nyíl</string>
<string name="gesture_key_caps_lock">Caps Lock</string>
<string name="gesture_key_num_lock">Num Lock</string>
<string name="gesture_key_scroll_lock">Scroll Lock</string>
<string name="gesture_key_left">Bal %1$s</string>
<string name="gesture_key_right">Jobb %1$s</string>
<string name="gesture_key_shift">Shift</string>
</resources>
+50 -4
View File
@@ -538,7 +538,7 @@
<string name="perf_gpuclock_hint">Csak Adreno. Kevesebb akadás, nagyobb akkumulátorhasználat és hőtermelés.</string>
<string name="perf_fixes">Munkamenet-javítások</string>
<string name="perf_sysmem">Turnip: sysmem renderelés</string>
<string name="perf_sysmem_hint">Gyorsabb a Steamhez és a legtöbb játékhoz. Adreno 710/720/722-n szükséges.</string>
<string name="perf_sysmem_hint">Gyorsabb a Steam kliensnek; játékokban a meghajtó menetenként választ a csempés és a rendszermemóriás renderelés között. Adreno 710/720/722-n szükséges.</string>
<string name="perf_xalia">A Steam xalia segítőjének kihagyása</string>
<string name="perf_fastsync">droiddeck-ntsync (kísérleti)</string>
<string name="perf_fastsync_hint">A Linux ntsync illesztőprogram felhasználói szintű helyettesítője, teszteléshez. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes.</string>
@@ -1924,6 +1924,8 @@
<string name="stores_title">Áruházak</string>
<string name="stores_settings">Áruházbeállítások</string>
<string name="stores_settings_eyebrow">Beállítások</string>
<string name="stores_tabs_arrange">Fülek rendezése</string>
<string name="stores_tabs_arrange_done">Kész</string>
<string name="stores_chip_signed_out">(nincs bejelentkezve)</string>
<string name="stores_tab_store">Áruház</string>
<string name="stores_tab_library">Könyvtár</string>
@@ -2150,7 +2152,7 @@
<string name="logo_pos_size">Méret</string>
<string name="logo_pos_save">Mentés</string>
<string name="logo_pos_reset">Vissza az alapértelmezettre</string>
<string name="logo_pos_hint">A játék háttere fölött rajzolódik ki, ahogy a Steam is teszi. A pontos pozíció néhány pixellel eltérhet.</string>
<string name="logo_pos_hint">Ez a pontos hely néhány képpontnyit eltérhet.</string>
<string name="logo_pos_no_logo">Ennek a játéknak még nincs logója. Válassz egyet a Logó alatt, hogy itt lásd.</string>
<string name="logo_pos_failed">A logó pozícióját nem sikerült menteni. Próbáld újra.</string>
<string name="art_background">Háttér</string>
@@ -2679,7 +2681,7 @@
<string name="boot_movie">Indítóvideó</string>
<string name="boot_movie_video">Videó</string>
<string name="boot_movie_droiddeck">DroidDeck · %1$d fps</string>
<string name="boot_movie_note">A 60 fps simább, de jobban terheli a processzort a Steam indulása közben. A Steam saját beállításaiban választott indítóvideó elsőbbséget élvez.</string>
<string name="boot_movie_note">A 60 fps simább, de jobban terheli a processzort a Steam indulása közben. A Steam saját beállításaiban választott videó itt is megjelenik.</string>
<string name="boot_movie_sound">Hang</string>
<string name="boot_movie_custom">Saját videó</string>
<string name="boot_movie_custom_hint">MP4, MOV, MKV vagy WebM. Más formátumokat és a %1$d MB-nál nagyobb fájlokat az eszköz 720p WebM-re alakítja, legfeljebb %2$d másodpercig.</string>
@@ -2693,6 +2695,51 @@
<string name="boot_movie_converting_started">%1$s átalakítása a Steamhez. Hagyd nyitva a DroidDecket.</string>
<string name="boot_movie_unsupported">Ez az eszköz nem tudja dekódolni a videót.</string>
<string name="boot_movie_import_failed">Nem sikerült importálni a videót</string>
<string name="boot_movie_remove_confirm">Eltávolítod: %1$s?</string>
<string name="boot_movie_remove_confirm_body">Ezzel törlődik a videó a DroidDeckből és a Steam videómappájából.</string>
<string name="boot_movie_tab_installed">Telepítve</string>
<string name="boot_movie_tab_import">Importálás</string>
<string name="boot_movie_tab_repo">SteamDeckRepo</string>
<string name="boot_movie_tab_sound">Hang</string>
<string name="boot_movie_installed">Telepített videók</string>
<string name="boot_movie_browse">Tallózás</string>
<string name="boot_movie_source_droiddeck">DroidDeck</string>
<string name="boot_movie_source_import">Importált</string>
<string name="boot_movie_source_repo">SteamDeckRepo</string>
<string name="boot_movie_source_steam">Steam</string>
<string name="boot_movie_source_pointshop">Pontbolt</string>
<string name="boot_movie_report">Indítóvideó-jelentés</string>
<string name="boot_movie_report_hint">Szöveges fájlba menti, amit a DroidDeck és a Steam tud az indítóvideókról, hibajelentéshez csatolható.</string>
<string name="boot_movie_report_save">Mentés</string>
<string name="boot_movie_report_saved">Mentve: %1$s</string>
<string name="boot_movie_report_failed">A jelentést nem sikerült menteni.</string>
<string name="boot_movie_volume">Hangerő</string>
<string name="boot_movie_length">Hossz</string>
<string name="boot_movie_seconds">%1$d mp</string>
<string name="boot_movie_length_hint">A videó hány másodperce játszódik le. Ami ennél hosszabb, azt a végén levágjuk.</string>
<string name="boot_movie_repo_search">Keresés a SteamDeckRepo-ban</string>
<string name="boot_movie_repo_all">Minden eszköz</string>
<string name="boot_movie_repo_deck">Steam Deck</string>
<string name="boot_movie_repo_machine">Steam Machine</string>
<string name="boot_movie_repo_trending">Népszerű</string>
<string name="boot_movie_repo_downloads">Legtöbbet letöltött</string>
<string name="boot_movie_repo_likes">Legkedveltebb</string>
<string name="boot_movie_repo_newest">Legújabb</string>
<string name="boot_movie_repo_failed">Nem érhető el a SteamDeckRepo</string>
<string name="boot_movie_repo_retry">Újra</string>
<string name="boot_movie_repo_empty">Nem található videó</string>
<string name="boot_movie_repo_install">Telepítés</string>
<string name="boot_movie_repo_use">Használat</string>
<string name="boot_movie_in_use">Használatban</string>
<string name="boot_movie_repo_installed">%1$s telepítve</string>
<string name="boot_movie_repo_download_failed">Nem sikerült letölteni a videót</string>
<string name="boot_movie_repo_previous">Előző</string>
<string name="boot_movie_repo_next">Következő</string>
<string name="boot_movie_repo_page">%1$d. oldal</string>
<string name="boot_movie_repo_clear">Törlés</string>
<string name="boot_movie_repo_go_to_page">Ugrás az oldalra</string>
<string name="boot_movie_repo_preview">Előnézet</string>
<string name="boot_movie_repo_preview_failed">Az előnézetet nem sikerült lejátszani</string>
<string name="glasses_experimental">Luma Ultra kamerák — Kísérleti</string>
<string name="glasses_joycons">Joy-Con pozíciókövetés</string>
<string name="glasses_joycons_hint">Használd az okosszemüveg kameráit a párosított Joy-Con kontrollerek pozicionálásához. A forgatás és a gombbemenetek a Joy-Con kontrollerekről származnak. A pozíció akkor becsült, amikor a csuklód rejtve van. Helyettesíti a puszta kézzel működtetett kontrollereket, és szünetelteti a telefonkamera követést. Irányítsd a Joy-Con kontrollereket előre a követőközpont csatlakoztatásakor vagy visszaállításakor.</string>
@@ -2864,7 +2911,6 @@
<string name="spatial_hands_wait_stable">Várakozás stabil fejkövetésre; szükség esetén állítsa vissza a követőközpontot.</string>
<string name="spatial_old_frame">A kamerás megfigyelés túl régi; friss párra vár</string>
<string name="spatial_fit_ready">Fit %1$.2f px. Mérje meg a kiválasztott távolságot a bal oldali kamera és a kalibrációs célközép között, majd érvényesítse.</string>
<string name="spatial_opencv_error">Az OpenCV nem érhető el: %1$s</string>
<string name="spatial_restored">%1$d mentett nézet visszaállítva. Számítsa ki a kalibrációt az újrapróbálkozáshoz, vagy indítson új sorozatot, hogy többet rögzítsen.</string>
<string name="spatial_captured">Rögzített %1$d / 24 megtekintés. Tartsa rögzítve a kalibrációs célt; mozgassa az okosszemüveget a következő nézethez.</string>
<string name="spatial_capture_corners">Rögzítés elutasítva: %1$d / 12 szükséges sarok mindkét kamerán látható. Mutasson többet a kalibrációs célból, tartsa mozdulatlanul, majd próbálja újra.</string>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mode_touch_gestures">Gestur</string>
<string name="gesture_settings">Setelan gestur layar sentuh</string>
<string name="gesture_settings_hint">Sesuaikan gestur mouse dan keyboard. Pilih Gestur sebagai mode sentuh untuk menggunakannya.</string>
<string name="gesture_tap">Ketuk</string>
<string name="gesture_drag">Seret dengan satu jari</string>
<string name="gesture_long_press">Tekan lama</string>
<string name="gesture_double_tap">Ketuk dua kali (klik kiri dua kali)</string>
<string name="gesture_two_tap">Ketuk dengan dua jari</string>
<string name="gesture_two_drag">Seret dengan dua jari</string>
<string name="gesture_two_hold">Tahan dengan dua jari</string>
<string name="gesture_three_tap">Ketuk dengan tiga jari</string>
<string name="gesture_three_drag">Seret dengan tiga jari</string>
<string name="gesture_three_hold">Tahan dengan tiga jari</string>
<string name="gesture_pinch">Cubit untuk memperbesar</string>
<string name="gesture_hold_button">Tetap tekan tombol</string>
<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>
<string name="gesture_left_click">Klik kiri</string>
<string name="gesture_right_click">Klik kanan</string>
<string name="gesture_middle_click">Klik tengah</string>
<string name="gesture_keyboard">Tampilkan keyboard</string>
<string name="gesture_left_drag">Klik kiri dan seret</string>
<string name="gesture_right_drag">Klik kanan dan seret</string>
<string name="gesture_middle_pan">Geser dengan tombol tengah mouse</string>
<string name="gesture_middle_pan_inverted">Geser dengan tombol tengah mouse (terbalik)</string>
<string name="gesture_arrows">Tombol panah</string>
<string name="gesture_arrows_inverted">Tombol panah (terbalik)</string>
<string name="gesture_wasd_inverted">WASD (terbalik)</string>
<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>
<string name="gesture_no_actions">Tidak ada tindakan yang cocok. Coba nama tombol atau tindakan lain.</string>
<string name="gesture_group_mouse">Mouse</string>
<string name="gesture_group_app">Tindakan aplikasi</string>
<string name="gesture_group_navigation">Navigasi</string>
<string name="gesture_group_letters">Huruf</string>
<string name="gesture_group_numbers">Angka</string>
<string name="gesture_group_function">Tombol fungsi</string>
<string name="gesture_group_modifiers">Tombol pengubah</string>
<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>
<string name="gesture_key_numpad">Keypad numerik %1$s</string>
<string name="gesture_key_escape">Escape</string>
<string name="gesture_key_backspace">Backspace</string>
<string name="gesture_key_enter">Enter</string>
<string name="gesture_key_tab">Tab</string>
<string name="gesture_key_space">Space</string>
<string name="gesture_key_delete">Delete</string>
<string name="gesture_key_insert">Insert</string>
<string name="gesture_key_home">Home</string>
<string name="gesture_key_end">End</string>
<string name="gesture_key_page_up">Page Up</string>
<string name="gesture_key_page_down">Page Down</string>
<string name="gesture_key_print_screen">Print Screen</string>
<string name="gesture_key_up">Panah atas</string>
<string name="gesture_key_down">Panah bawah</string>
<string name="gesture_key_left_arrow">Panah kiri</string>
<string name="gesture_key_right_arrow">Panah kanan</string>
<string name="gesture_key_caps_lock">Caps Lock</string>
<string name="gesture_key_num_lock">Num Lock</string>
<string name="gesture_key_scroll_lock">Scroll Lock</string>
<string name="gesture_key_left">%1$s kiri</string>
<string name="gesture_key_right">%1$s kanan</string>
<string name="gesture_key_shift">Shift</string>
</resources>
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more