mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-20 14:14:44 -04:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a820402c | ||
|
|
0f1b82aa26 | ||
|
|
8b279001a5 | ||
|
|
61254786e6 | ||
|
|
ece1df0da8 | ||
|
|
49d1311808 | ||
|
|
a38514afa0 | ||
|
|
31a6c3d033 | ||
|
|
99ab6dd64e | ||
|
|
9b5df0d224 | ||
|
|
aeb0fa57e3 | ||
|
|
ba582dea65 | ||
|
|
8074ec44e8 | ||
|
|
fdab3cea82 | ||
|
|
b26b43fbd0 | ||
|
|
4ba3e442a8 | ||
|
|
57799bef5a | ||
|
|
cee84fd12a | ||
|
|
48daa064c9 | ||
|
|
a42ad82bcb | ||
|
|
06e819f864 | ||
|
|
4c81c98f12 | ||
|
|
3a8d6aaabb | ||
|
|
64b966af49 | ||
|
|
583c6805fd | ||
|
|
e8ee11612f | ||
|
|
7855bec6a1 | ||
|
|
2a423a729a | ||
|
|
01f41e0553 | ||
|
|
3dd0045874 | ||
|
|
6bbcf3ad8b |
No files matched your search
+59
-10
@@ -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
|
||||
|
||||
@@ -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 .)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user