mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-10 10:01:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb4a6d0480 | ||
|
|
0e9a43476f | ||
|
|
98ab71a814 | ||
|
|
cdff94e72c | ||
|
|
aa1984bb94 | ||
|
|
634cb390c9 | ||
|
|
13c8edb3d5 | ||
|
|
1ec2b696b7 | ||
|
|
621fd8d43e | ||
|
|
17936530aa | ||
|
|
ae3e9509f4 | ||
|
|
6bcd431ca6 | ||
|
|
836340f3a2 | ||
|
|
1b19837fc6 | ||
|
|
c85b84a964 | ||
|
|
75a0230304 | ||
|
|
7c89c14a84 | ||
|
|
b837611efe | ||
|
|
bf434b2652 | ||
|
|
35941bb9e7 | ||
|
|
96a12465e0 | ||
|
|
88d6b70881 | ||
|
|
29823bb1bf | ||
|
|
92a7124589 | ||
|
|
bf95a40e3e | ||
|
|
1355cb3a2b | ||
|
|
51407155fc | ||
|
|
b4a953c659 | ||
|
|
5e438e9248 | ||
|
|
6b110581ac | ||
|
|
92742f37c1 | ||
|
|
ecd916d43f | ||
|
|
a7c10e3f51 | ||
|
|
1760f008ae | ||
|
|
3bb279f635 | ||
|
|
3fef1f3fa1 | ||
|
|
a4f26799ab | ||
|
|
08a6a3cba8 | ||
|
|
005fec2125 | ||
|
|
2e70b8ed73 | ||
|
|
74c0f2127a | ||
|
|
2bd7df5e1a | ||
|
|
b7b96875a1 | ||
|
|
51e5e3a2a4 | ||
|
|
c755d05473 | ||
|
|
aa883830ca | ||
|
|
45e39b7aaf | ||
|
|
bc99cacd02 | ||
|
|
2beeb715fd | ||
|
|
39469e6e8f | ||
|
|
15baa296bb | ||
|
|
e4a4775be5 | ||
|
|
77ef10d601 | ||
|
|
914b3588e1 | ||
|
|
2ade269be3 | ||
|
|
0d034b1996 |
No files matched your search
+12
-12
@@ -313,6 +313,11 @@ jobs:
|
||||
- name: Build the DiRT 3 / DiRT Showdown ws2_32 stand-in
|
||||
run: python3 tools/components/prepare.py dirt-ws2
|
||||
|
||||
- name: Cache graphics driver inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/build/quest-inputs
|
||||
key: quest-inputs-${{ hashFiles('tools/quest/build-driver.py') }}
|
||||
- name: Prepare graphics driver variants
|
||||
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
- name: Record and pack prepared components
|
||||
@@ -558,9 +563,11 @@ jobs:
|
||||
- name: Cache checksum-pinned pack inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/droiddeck-pack-inputs
|
||||
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
|
||||
restore-keys: pack-inputs-v1-
|
||||
path: |
|
||||
~/.cache/droiddeck-pack-inputs
|
||||
~/.cache/droiddeck-build
|
||||
key: pack-inputs-v2-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env', 'tools/powervr/release.json') }}
|
||||
restore-keys: pack-inputs-v2-
|
||||
- name: Build the add-on packs
|
||||
run: |
|
||||
python3 tools/powervr/fetch.py
|
||||
@@ -785,18 +792,11 @@ jobs:
|
||||
cd app/src/main/rust/stores
|
||||
cargo test --locked 2>&1 | tail -60
|
||||
|
||||
python-runner:
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/pick-runner.yml
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
|
||||
python-tests:
|
||||
name: Python and native regression tests
|
||||
needs: [source, python-runner]
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ${{ fromJSON(needs.python-runner.outputs.labels) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
labels: ${{ steps.runner.outputs.labels || '"ubuntu-24.04"' }}
|
||||
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
|
||||
steps:
|
||||
- name: Pick the compile runner
|
||||
id: runner
|
||||
@@ -36,3 +36,25 @@ jobs:
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Pick the fork runner
|
||||
id: fork
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
labels='"ubuntu-24.04"'
|
||||
gist=https://api.github.com/gists/0c7ed7ad6d6493de84f5123689c64ae6
|
||||
body=$(curl -fsS --max-time 15 -H "Authorization: Bearer $GH_TOKEN" "$gist" 2>/dev/null || curl -fsS --max-time 15 "$gist" 2>/dev/null || echo '{}')
|
||||
IDLE_AT=$(jq -r 'select(.owner.login == "maxjivi05") | .files["idle-at"].content // "0"' <<< "$body" 2>/dev/null || echo 0)
|
||||
echo "Heartbeat: ${IDLE_AT:-none}"
|
||||
age=-1
|
||||
if [[ "${IDLE_AT:-0}" =~ ^[0-9]+$ ]] && [ "${IDLE_AT:-0}" -gt 0 ]; then
|
||||
age=$(( $(date +%s) - IDLE_AT ))
|
||||
fi
|
||||
if [ "$age" -ge 0 ] && [ "$age" -lt 180 ]; then
|
||||
labels='"droiddeck-fork"'
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
|
||||
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No source withheld.</b></p>
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
|
||||
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.1/DroidDeck-0.7.1.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.7.1%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.7.1"></a>
|
||||
<a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a>
|
||||
</p>
|
||||
|
||||
@@ -36,9 +36,9 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
|
||||
|
||||
## Install
|
||||
|
||||
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx is experimental. Mali, Xclipse, PowerVR and Adreno 710 are unsupported. No root needed.
|
||||
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed.
|
||||
2. About 3 GB free for the runtime, plus 1.1 GB for the desktop and emulators.
|
||||
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk), install the Linux runtime, press **Play** and sign in to Steam.
|
||||
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.1/DroidDeck-0.7.1.apk), install the Linux runtime, press **Play** and sign in to Steam.
|
||||
4. On Android 14 and newer, turn on **Disable child process restrictions** in Developer options. On Android 12 and 13, Steam's first launch shows a **Fix it for me** button that walks you through it.
|
||||
|
||||
Smart glasses setup is in the [support matrix](docs/smart-glasses-support.md). Release notes are in [docs/releases](docs/releases).
|
||||
@@ -79,7 +79,8 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
|
||||
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
|
||||
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
|
||||
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
|
||||
- Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch.
|
||||
- Games and AppImage, Flatpak and Snap programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`.
|
||||
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
|
||||
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
|
||||
- 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).
|
||||
|
||||
+2
-2
@@ -131,8 +131,8 @@ android {
|
||||
applicationId "com.droiddeck.launcher"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
versionCode 17
|
||||
versionName "0.6.9"
|
||||
versionCode 19
|
||||
versionName "0.7.1"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
|
||||
@@ -91,14 +91,6 @@
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
|
||||
</service>
|
||||
<service android:name=".session.BottomNotificationListener"
|
||||
android:label="@string/bottom_notifications_listener"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
|
||||
android:process=":shortcuts_sync">
|
||||
<intent-filter>
|
||||
@@ -352,6 +344,14 @@
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
<receiver
|
||||
android:name=".agent.AgentCallReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.AGENT_CALL" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <linux/uinput.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
@@ -208,23 +209,33 @@ struct FakeConfig {
|
||||
bool uinput_enabled;
|
||||
};
|
||||
|
||||
// The first allocation here can start the host program's own allocator, which may call a hooked
|
||||
// function (mimalloc probes NUMA nodes with access()) and land back in config(). A guarded static
|
||||
// would throw a recursive-init error there, so the fields are filled without allocating, and a
|
||||
// reentry on the initializing thread gets them while other threads wait for the fork handlers.
|
||||
static FakeConfig &config() {
|
||||
static FakeConfig *cfg = [] {
|
||||
auto *c = new FakeConfig();
|
||||
const char *dir = getenv("FAKE_EVDEV_DIR");
|
||||
c->hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
|
||||
c->udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
|
||||
c->vibration_enabled =
|
||||
getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
|
||||
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
|
||||
c->xbox360_identity = identity && !strcmp(identity, "xbox360");
|
||||
c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
|
||||
pthread_atfork(controller_fork_prepare, controller_fork_parent,
|
||||
controller_fork_child);
|
||||
Logger::init();
|
||||
return c;
|
||||
}();
|
||||
return *cfg;
|
||||
static FakeConfig cfg;
|
||||
static std::atomic<int> state{0};
|
||||
static __thread bool initializing __attribute__((tls_model("initial-exec")));
|
||||
if (state.load(std::memory_order_acquire) == 2 || initializing) return cfg;
|
||||
int expected = 0;
|
||||
if (!state.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) {
|
||||
while (state.load(std::memory_order_acquire) != 2) sched_yield();
|
||||
return cfg;
|
||||
}
|
||||
initializing = true;
|
||||
const char *dir = getenv("FAKE_EVDEV_DIR");
|
||||
cfg.hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
|
||||
cfg.udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
|
||||
cfg.vibration_enabled = getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
|
||||
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
|
||||
cfg.xbox360_identity = identity && !strcmp(identity, "xbox360");
|
||||
cfg.uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
|
||||
pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child);
|
||||
Logger::init();
|
||||
state.store(2, std::memory_order_release);
|
||||
initializing = false;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
static std::recursive_mutex &controller_mutex() {
|
||||
|
||||
@@ -213,7 +213,7 @@ static struct {
|
||||
} g_map;
|
||||
static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static float g_game_picture_rect[4];
|
||||
static int g_game_picture_top;
|
||||
static int g_game_picture_placed;
|
||||
|
||||
static int64_t g_swap_retry_at_ns;
|
||||
static int g_swap_fail_logged;
|
||||
@@ -362,7 +362,7 @@ int vkp_completion_unknown(void) { return g_dev_state == -3; }
|
||||
|
||||
void vk_present_set_scale_mode(int mode, int alignment) {
|
||||
if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_RIGHT) alignment = VKP_ALIGN_CENTER;
|
||||
g_mode = mode;
|
||||
g_align = alignment;
|
||||
}
|
||||
@@ -383,6 +383,8 @@ static const char *align_name(int a) {
|
||||
case VKP_ALIGN_BOTTOM: return "bottom";
|
||||
case VKP_ALIGN_PICTURE_TOP: return "picture top";
|
||||
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
|
||||
case VKP_ALIGN_PICTURE_LEFT: return "picture left";
|
||||
case VKP_ALIGN_PICTURE_RIGHT: return "picture right";
|
||||
default: return "center";
|
||||
}
|
||||
}
|
||||
@@ -420,9 +422,11 @@ static void update_map(int scene_w, int scene_h) {
|
||||
}
|
||||
if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0;
|
||||
else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky;
|
||||
else if (align == VKP_ALIGN_PICTURE_LEFT) g_map.off_x = 0;
|
||||
else if (align == VKP_ALIGN_PICTURE_RIGHT) g_map.off_x = W - scene_w * g_map.kx;
|
||||
g_map.valid = 1;
|
||||
pthread_mutex_lock(&g_game_picture_lock);
|
||||
g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
|
||||
g_game_picture_placed = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM || align == VKP_ALIGN_PICTURE_LEFT || align == VKP_ALIGN_PICTURE_RIGHT;
|
||||
g_game_picture_rect[0] = g_map.off_x;
|
||||
g_game_picture_rect[1] = g_map.off_y;
|
||||
g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx;
|
||||
@@ -436,7 +440,7 @@ static void update_map(int scene_w, int scene_h) {
|
||||
|
||||
int vkp_game_picture_rect(float rect[4]) {
|
||||
pthread_mutex_lock(&g_game_picture_lock);
|
||||
int active = g_game_picture_top;
|
||||
int active = g_game_picture_placed;
|
||||
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
|
||||
pthread_mutex_unlock(&g_game_picture_lock);
|
||||
return active;
|
||||
|
||||
@@ -15,7 +15,9 @@ int vkp_quest_layer_presentation(void);
|
||||
|
||||
enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3,
|
||||
VKP_MODE_INTEGER = 4 };
|
||||
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 };
|
||||
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2,
|
||||
VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4,
|
||||
VKP_ALIGN_PICTURE_LEFT = 5, VKP_ALIGN_PICTURE_RIGHT = 6 };
|
||||
void vk_present_set_scale_mode(int mode, int alignment);
|
||||
int vkp_game_picture_rect(float rect[4]);
|
||||
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
|
||||
|
||||
@@ -603,11 +603,19 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
if (!linux && id == TurnipDriver.AUTO) {
|
||||
DriverBundle.selectDisplay(activity, id)
|
||||
pairModeOpen = false
|
||||
lastAutoCheck = 0L
|
||||
setMode(auto = true)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
else -> DriverBundle.selectDisplay(activity, id)
|
||||
}
|
||||
pairModeOpen = false
|
||||
setMode(auto = false, mix = true)
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -873,6 +874,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
Library.recordPlayed(this, game)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
|
||||
.returnTo(returnHome)
|
||||
@@ -888,7 +890,15 @@ class FrontEndActivity : ComponentActivity() {
|
||||
putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
|
||||
}
|
||||
}, steamSession = true)
|
||||
}
|
||||
.playing(game), steamSession = true)
|
||||
}
|
||||
|
||||
private fun Intent.playing(game: Library.SteamGame): Intent =
|
||||
apply { if (game.library == Library.ADDED) putExtra(SessionActivity.EXTRA_PLAYED_GAME, game.gameIdString) }
|
||||
|
||||
private fun recordPlayed(intent: Intent) {
|
||||
intent.getStringExtra(SessionActivity.EXTRA_PLAYED_GAME)?.let { Library.recordPlayed(this, it) }
|
||||
}
|
||||
|
||||
private fun Intent.returnTo(returnHome: Boolean): Intent {
|
||||
@@ -915,14 +925,17 @@ class FrontEndActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
|
||||
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
|
||||
.playing(game)
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, next, returnHome) -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
|
||||
recordPlayed(next)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
@@ -1123,6 +1136,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
val prootPack by com.droiddeck.launcher.core.AddonPack.PROOT.state.collectAsState()
|
||||
androidx.compose.runtime.LaunchedEffect(prootPack) {
|
||||
if (prootPack == com.droiddeck.launcher.core.AddonPack.State.Ready && installed == null) refresh()
|
||||
}
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
|
||||
@@ -2276,14 +2293,14 @@ class FrontEndActivity : ComponentActivity() {
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
handToBattleNet(full) { pendingSwitch = next } -> {}
|
||||
handToBattleNet(full, next) -> {}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
}, "battlenet-open").start()
|
||||
}
|
||||
|
||||
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
|
||||
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
|
||||
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
|
||||
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
@@ -2294,10 +2311,13 @@ class FrontEndActivity : ComponentActivity() {
|
||||
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
|
||||
ui.post {
|
||||
if (isDestroyed) return@post
|
||||
if (!handed) otherwise()
|
||||
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
if (!handed) pendingSwitch = next
|
||||
else {
|
||||
recordPlayed(next)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
}
|
||||
}, "battlenet-request").start()
|
||||
return true
|
||||
|
||||
@@ -121,6 +121,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionLoadingDecision
|
||||
import com.droiddeck.launcher.wayland.HdrSupport
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
@@ -172,12 +173,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val alignment = if (!active) ALIGN_CENTER else when (picturePosition) {
|
||||
"top" -> ALIGN_PICTURE_TOP
|
||||
"bottom" -> ALIGN_PICTURE_BOTTOM
|
||||
"left" -> ALIGN_PICTURE_LEFT
|
||||
"right" -> ALIGN_PICTURE_RIGHT
|
||||
else -> ALIGN_CENTER
|
||||
}
|
||||
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment)
|
||||
}
|
||||
private var unfoldedPanel by mutableStateOf<OnScreenControls?>(null)
|
||||
private var unfoldedColumn: android.widget.LinearLayout? = null
|
||||
private var unfoldedDeckSurface: com.droiddeck.launcher.ui.InputSurfaceView? = null
|
||||
private var unfoldedDeckPanel by mutableStateOf<com.droiddeck.launcher.input.DeckPanel?>(null)
|
||||
private var deckPanelLayouts: Map<com.droiddeck.launcher.input.DeckPanel, com.droiddeck.launcher.input.SavedInputLayout?> = emptyMap()
|
||||
private lateinit var sessionRoot: FrameLayout
|
||||
private var inputRouter: InputRouter? = null
|
||||
private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null
|
||||
@@ -283,6 +289,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var directHoldBehavior by mutableStateOf(com.droiddeck.launcher.input.TouchGestureConfig.MOUSE_BEHAVIOR_CLICK)
|
||||
private var relativeMouseMode by mutableIntStateOf(0)
|
||||
@Volatile private var appliedRelativeMouseMode: Int? = null
|
||||
private val agentReadyListener: () -> Unit = { relativeMouseRetries = 0; requestRelativeMouse() }
|
||||
private val appTextFocus get() = wowChat.request != null
|
||||
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
|
||||
private var wowChatBottom: SecondScreenPresentation? = null
|
||||
@@ -565,6 +572,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var sessionActivityDisplayId = Display.DEFAULT_DISPLAY
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private var secondScreenPresentation: SecondScreenPresentation? = null
|
||||
private val inputWindowDisplays = mutableListOf<Int>()
|
||||
private val bottomScreenListener: () -> Unit = {
|
||||
if (secondScreenPresentation?.display?.displayId?.let(com.droiddeck.launcher.input.DualScreen::taken) == true) closeSecondScreen(reset = false)
|
||||
updateBottomGame()
|
||||
@@ -632,6 +640,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
padBridge?.syncConnectedControllers()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
requestRelativeMouse()
|
||||
updateHandCamera()
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
@@ -647,12 +656,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (::padMouse.isInitialized) padMouse.reset()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
requestRelativeMouse()
|
||||
updateHandCamera()
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) {
|
||||
joyConVr?.refresh()
|
||||
refreshControls()
|
||||
requestRelativeMouse()
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
}
|
||||
@@ -661,6 +672,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
AgentGuest.readyListener = agentReadyListener
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
runtimeRemovalBlocked = true
|
||||
@@ -977,7 +989,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
} else {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay({ cursorPos }, cursorVisible && !uiPointer && !bottomOutputActive,
|
||||
CursorOverlay({ cursorPos }, cursorVisible && (!uiPointer || onlyPcKeyboardOpen()) && !bottomOutputActive,
|
||||
resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (memoryHud.text.isNotEmpty()) HudText(memoryHud.text, hudColor, hudOpacity)
|
||||
@@ -1222,7 +1234,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
|
||||
micAvailable = SessionState.micAvailable && !SessionState.suspended,
|
||||
micEnabled = SessionState.micEnabled,
|
||||
@@ -1443,6 +1455,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
},
|
||||
onClose = { drawerOpen = false },
|
||||
onDismissScreen = if (docked) ({ selectSecondScreenMode(SecondScreenMode.NONE) }) else null,
|
||||
components = drawerComponents,
|
||||
onComponentsRefresh = {
|
||||
refreshDrawerComponents()
|
||||
@@ -1955,6 +1968,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val nextRoutedTouchMode = effectiveTouchMode()
|
||||
if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset()
|
||||
routedTouchMode = nextRoutedTouchMode
|
||||
requestRelativeMouse()
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
@@ -2225,6 +2239,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
intent.getStringExtra(SessionService.EXTRA_GAME_ID),
|
||||
sessionActivityDisplayId,
|
||||
)
|
||||
intent.getStringExtra(EXTRA_PLAYED_GAME)?.let { com.droiddeck.launcher.frontend.Library.recordPlayed(this, it) }
|
||||
}
|
||||
applyFrameGen()
|
||||
applyGameAlignment()
|
||||
@@ -2315,9 +2330,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
if (::twoFingerGestures.isInitialized) twoFingerGestures.cancel()
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
scrollTouchGesture = false
|
||||
gesturePointerReady = false
|
||||
if (sessionTouchDown) dismissTouch = true
|
||||
}
|
||||
|
||||
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
|
||||
@@ -2421,6 +2438,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun watchSession() {
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
var ticks = 0
|
||||
val loadingStartedAt = SystemClock.elapsedRealtime()
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (!watching) return
|
||||
@@ -2433,6 +2451,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (loading.visible && !loading.ended && !SessionState.running && !installingRuntime &&
|
||||
SessionLoadingDecision.shouldReturnToLauncher(
|
||||
loadingStartedAt, SystemClock.elapsedRealtime(), SessionState.running, SessionState.phase)) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (loading.visible && !loading.ended) {
|
||||
if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile)
|
||||
if (ticks++ % 2 == 0) loading.tick()
|
||||
@@ -2457,8 +2481,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val hint = sessionExitHint(status)
|
||||
runOnUiThread {
|
||||
if (isFinishing || isDestroyed) return@runOnUiThread
|
||||
if (status == 0) SessionState.relaunch?.let { next ->
|
||||
SessionState.relaunch = null
|
||||
if (status == 0) SessionState.takeRelaunch()?.let { next ->
|
||||
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
|
||||
setIntent(next)
|
||||
@@ -2826,6 +2849,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return onScreenControls?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
steamTouchControls?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
unfoldedDeckSurface?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
unfoldedColumn?.getChildAt(0)?.let { takes(it) { _, _ -> true } } == true
|
||||
}
|
||||
|
||||
@@ -2972,7 +2996,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun applyRelativeMouse() {
|
||||
val mode = desiredRelativeMouseMode
|
||||
val wanted = desiredRelativeMouseMode
|
||||
val touchpadRoute = com.droiddeck.launcher.input.TouchRouter.decide(com.droiddeck.launcher.input.TouchRouter.Snapshot(
|
||||
uiBlocking = false,
|
||||
mode = routedTouchMode,
|
||||
scrollLayer = false,
|
||||
steamSession = SessionState.mode == SessionService.MODE_STEAM,
|
||||
steamDesktopUi = SessionState.steamUi == "desktop",
|
||||
)) == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD
|
||||
val mode = com.droiddeck.launcher.input.TouchGestureConfig.gamescopeRelativeMouse(wanted, mouseAttached(), touchpadRoute)
|
||||
if (appliedRelativeMouseMode == mode) return
|
||||
val request = org.json.JSONObject()
|
||||
.put("kind", "exec")
|
||||
@@ -2988,7 +3020,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.w(TAG, "gamescope did not take relative mouse $mode: ${result.exceptionOrNull() ?: answer}")
|
||||
if (relativeMouseRetries++ < RELATIVE_MOUSE_MAX_RETRIES) uiHandler.postDelayed({ requestRelativeMouse() }, 2_000)
|
||||
}
|
||||
if (desiredRelativeMouseMode != mode) requestRelativeMouse()
|
||||
if (desiredRelativeMouseMode != wanted) requestRelativeMouse()
|
||||
}
|
||||
|
||||
private fun refreshGestureConfig() {
|
||||
@@ -3183,12 +3215,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
cursorVisible = false
|
||||
return
|
||||
}
|
||||
val rect = drawnRect() ?: return
|
||||
val rect = if (glassesOutputActive) pointerSurfaceRect() else drawnRect()
|
||||
val pointerRect = rect ?: return
|
||||
val position = cursorPos
|
||||
val x = if (position.x >= 0f) position.x.coerceIn(rect.left, rect.right) else rect.centerX()
|
||||
val y = if (position.y >= 0f) position.y.coerceIn(rect.top, rect.bottom) else rect.centerY()
|
||||
val x = if (position.x >= 0f) position.x.coerceIn(pointerRect.left, pointerRect.right) else pointerRect.centerX()
|
||||
val y = if (position.y >= 0f) position.y.coerceIn(pointerRect.top, pointerRect.bottom) else pointerRect.centerY()
|
||||
val out = SessionState.outputSize
|
||||
movePointer(x + dx * rect.width() / out.first, y + dy * rect.height() / out.second)
|
||||
val nextX = (x + dx * pointerRect.width() / out.first)
|
||||
.coerceIn(pointerRect.left, pointerRect.right)
|
||||
val nextY = (y + dy * pointerRect.height() / out.second)
|
||||
.coerceIn(pointerRect.top, pointerRect.bottom)
|
||||
movePointer(nextX, nextY)
|
||||
// Make frame-callback updates visible to drawing before this frame's traversal.
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
}
|
||||
@@ -3378,6 +3415,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
var presentation = secondScreenPresentation
|
||||
if (presentation?.display?.displayId != target.displayId) {
|
||||
if (target.displayId in inputWindowDisplays) {
|
||||
panelMode(mode)
|
||||
return
|
||||
}
|
||||
dropDockedDrawer()
|
||||
presentation?.closeControls()
|
||||
presentation = secondScreenPresentation(target)
|
||||
@@ -3432,50 +3473,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun bottomPages(): List<BottomPage> = sessionBottomPages(BottomPageActions(
|
||||
takeScreenshot = ::captureScreenshot,
|
||||
fps = { if (::hud.isInitialized) hud.liveFps else null },
|
||||
openSettings = ::openSettingsBelow,
|
||||
runningGame = { gestureGame },
|
||||
))
|
||||
|
||||
private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) {
|
||||
val presentation = secondScreenPresentation
|
||||
if (presentation == null) {
|
||||
if (!bottomOutputActive) return
|
||||
listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
|
||||
runCatching { startActivity(android.content.Intent(it)) }
|
||||
.onFailure { e -> Log.w(TAG, "could not open ${it.action}", e) }
|
||||
.isSuccess
|
||||
}
|
||||
return
|
||||
}
|
||||
val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
|
||||
val opened = listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
|
||||
runCatching { startActivity(android.content.Intent(it).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle()) }
|
||||
.onFailure { e -> Log.w(TAG, "could not open ${it.action} on display ${presentation.display.displayId}", e) }
|
||||
.isSuccess
|
||||
}
|
||||
if (!opened) return
|
||||
presentation.hide()
|
||||
var checks = 0
|
||||
fun showAgain() {
|
||||
try {
|
||||
presentation.show()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not show the dashboard again", e)
|
||||
if (secondScreenPresentation === presentation) closeSecondScreen(reset = true)
|
||||
}
|
||||
}
|
||||
uiHandler.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (secondScreenPresentation !== presentation || presentation.isShowing) return
|
||||
if (done()) {
|
||||
showAgain()
|
||||
return
|
||||
}
|
||||
if (++checks < SETTINGS_WATCH_CHECKS) uiHandler.postDelayed(this, SETTINGS_WATCH_MS) else showAgain()
|
||||
}
|
||||
}, SETTINGS_WATCH_MS)
|
||||
}
|
||||
|
||||
private fun captureScreenshot(onDone: (Boolean) -> Unit) {
|
||||
val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (bottomOutputActive) bottomGame?.surfaceView
|
||||
else if (::surfaceView.isInitialized) surfaceView else null
|
||||
@@ -3518,8 +3518,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val main = window.decorView.display
|
||||
if (role == InputRole.PRIMARY) return bottomGame?.display ?: main
|
||||
if (bottomGame != null) return main
|
||||
return displayManager.getDisplay(selectedSecondScreenDisplay)
|
||||
?: secondScreenDisplays.firstOrNull()?.let { displayManager.getDisplay(it.id) }
|
||||
return com.droiddeck.launcher.input.SecondScreenDisplays.editorCandidates(selectedSecondScreenDisplay,
|
||||
secondScreenDisplays.map { it.id }, com.droiddeck.launcher.input.DualScreen::taken).firstNotNullOfOrNull(displayManager::getDisplay)
|
||||
}
|
||||
|
||||
fun inputWindowShown(displayId: Int) { inputWindowDisplays += displayId }
|
||||
|
||||
fun inputWindowClosed(displayId: Int) {
|
||||
inputWindowDisplays -= displayId
|
||||
if (started && displayId !in inputWindowDisplays && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
private fun refreshInputProfile() {
|
||||
@@ -3530,6 +3537,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
adaptiveSticks = primaryInputLayout?.adaptiveSticks
|
||||
quickButtons = primaryInputLayout?.takeIf { it.editable }?.let { l -> l.quickButtons.associateWith { it !in l.style.hidden } }.orEmpty()
|
||||
secondaryInputLayout = library.panelLayout(desktop, secondScreenMode.panel(this))
|
||||
deckPanelLayouts = com.droiddeck.launcher.input.DeckPanel.entries.associateWith { library.panelLayout(desktop, it.bindings) }
|
||||
inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native }
|
||||
inputUsesPointer = inputProfile.bindings.values.any { bindings ->
|
||||
bindings.values.any { it is InputAction.Pointer || it is InputAction.MouseButton || it is InputAction.Scroll }
|
||||
@@ -3543,6 +3551,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenControls?.updateLayout(primaryInputLayout)
|
||||
bottomInputControls?.updateLayout(primaryInputLayout)
|
||||
unfoldedPanel?.updateLayout(primaryInputLayout)
|
||||
if (unfoldedPanel != null) applyUnfoldedControls()
|
||||
showUnfoldedDeckPanel()
|
||||
updateOnScreenPad()
|
||||
secondScreenPresentation?.refreshControls()
|
||||
}
|
||||
@@ -3586,7 +3596,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val width = sessionRoot.width
|
||||
val height = sessionRoot.height
|
||||
val wanted = controlsBelowWanted()
|
||||
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
|
||||
val tabRow = unfoldedColumn?.getChildAt(0)?.height?.let {
|
||||
it - (com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()
|
||||
} ?: 0
|
||||
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height, tabRow)
|
||||
else FrameLayout.LayoutParams.MATCH_PARENT
|
||||
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
|
||||
if (params.height != gameHeight) {
|
||||
@@ -3594,11 +3607,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
|
||||
else "unfolded controls: game full screen")
|
||||
}
|
||||
val dock = com.droiddeck.launcher.input.OnScreenLayout.Dock(!com.droiddeck.launcher.session.SessionDisplay.foldable(this), SessionState.deckPad)
|
||||
val portrait = !com.droiddeck.launcher.session.SessionDisplay.foldable(this)
|
||||
val dock = if (SessionState.deckPad) com.droiddeck.launcher.input.OnScreenLayout.Dock.deck(portrait, deckPanelLayouts[com.droiddeck.launcher.input.DeckPanel.GAMEPAD])
|
||||
else com.droiddeck.launcher.input.OnScreenLayout.Dock(portrait, false)
|
||||
var panel = unfoldedPanel
|
||||
if (panel != null && (!wanted || panel.dock != dock)) {
|
||||
sessionRoot.removeView(unfoldedColumn)
|
||||
unfoldedColumn = null
|
||||
unfoldedDeckSurface = null
|
||||
unfoldedDeckPanel = null
|
||||
unfoldedPanel = null
|
||||
panel = null
|
||||
if (!wanted) updateOnScreenControls()
|
||||
@@ -3608,21 +3625,47 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
panel = OnScreenControls(this, padBridge, dock).also { it.updateLayout(primaryInputLayout) }
|
||||
val column = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
addView(unfoldedBar(), android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
(com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()))
|
||||
addView(unfoldedBar().apply {
|
||||
addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
|
||||
if (bottom - top != oldBottom - oldTop) post { applyUnfoldedControls() }
|
||||
}
|
||||
}, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||
addView(panel, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
}
|
||||
sessionRoot.addView(column, sessionRoot.indexOfChild(surfaceView) + 1)
|
||||
unfoldedColumn = column
|
||||
unfoldedPanel = panel
|
||||
showUnfoldedDeckPanel()
|
||||
updateOnScreenControls()
|
||||
}
|
||||
unfoldedColumn?.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
|
||||
}
|
||||
|
||||
private fun unfoldedBar(): View = com.droiddeck.launcher.ui.bottomScreenBarView(this) {
|
||||
com.droiddeck.launcher.ui.sessionBarModel(getString(R.string.deck_tab_gamepad), { chooseUnfoldedControls(false) },
|
||||
{ drawerOpen = true }, ::tapSteamButton, ::tapQam)
|
||||
val deck = unfoldedDeckPanel
|
||||
com.droiddeck.launcher.ui.sessionBarModel(getString(if (deck != null) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad),
|
||||
{ chooseUnfoldedControls(false) }, { drawerOpen = true }, ::tapSteamButton, ::tapQam,
|
||||
tabs = deck?.let { com.droiddeck.launcher.ui.deckPanelTabs(this, it, ::selectDeckPanel) })
|
||||
}
|
||||
|
||||
private fun showUnfoldedDeckPanel() {
|
||||
val column = unfoldedColumn
|
||||
val panel = unfoldedPanel
|
||||
val selected = SessionPrefs.deckPanel(this).takeIf { column != null && panel?.dock?.grips == true }
|
||||
unfoldedDeckPanel = selected
|
||||
val layout = selected?.takeIf { it != com.droiddeck.launcher.input.DeckPanel.GAMEPAD }?.let { deckPanelLayouts[it] }
|
||||
if (layout != null && unfoldedDeckSurface?.tag == layout) return
|
||||
unfoldedDeckSurface?.let { it.releaseInputs(); column?.removeView(it) }
|
||||
unfoldedDeckSurface = null
|
||||
if (column == null || panel == null) return
|
||||
if (layout == null) { panel.visibility = View.VISIBLE; return }
|
||||
panel.releaseAll()
|
||||
panel.visibility = View.GONE
|
||||
unfoldedDeckSurface = com.droiddeck.launcher.ui.InputSurfaceView(this, padBridge, layout.elements, InputRole.PRIMARY, layout.style, haptics = true).also {
|
||||
it.tag = layout
|
||||
column.addView(it, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
|
||||
@@ -3798,6 +3841,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
gestureTouchStreamGate.reset()
|
||||
routedTouchMode = next
|
||||
requestRelativeMouse()
|
||||
if (directTouchCursorHidden()) cursorVisible = false
|
||||
}
|
||||
|
||||
@@ -3890,8 +3934,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val width = outputView().width.takeIf { it > 0 } ?: return null
|
||||
val height = outputView().height.takeIf { it > 0 } ?: return null
|
||||
topPictureMapped = CompositorHost.isStarted && WaylandCompositor.nativeGamePictureRect(gamePictureBounds)
|
||||
if (topPictureMapped) return android.graphics.RectF(
|
||||
gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3])
|
||||
if (topPictureMapped) {
|
||||
val rect = android.graphics.RectF(
|
||||
gamePictureBounds[0], gamePictureBounds[1],
|
||||
gamePictureBounds[2], gamePictureBounds[3]
|
||||
)
|
||||
|
||||
if (glassesOutputActive) {
|
||||
val mode = glassesScreen?.display?.mode ?: return null
|
||||
val outputWidth = mode.physicalWidth.takeIf { it > 0 } ?: return null
|
||||
val outputHeight = mode.physicalHeight.takeIf { it > 0 } ?: return null
|
||||
val scaleX = width.toFloat() / outputWidth
|
||||
val scaleY = height.toFloat() / outputHeight
|
||||
|
||||
return android.graphics.RectF(
|
||||
rect.left * scaleX,
|
||||
rect.top * scaleY,
|
||||
rect.right * scaleX,
|
||||
rect.bottom * scaleY
|
||||
)
|
||||
}
|
||||
|
||||
return rect
|
||||
}
|
||||
val out = SessionState.outputSize
|
||||
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
|
||||
val drawnW = out.first * scale
|
||||
@@ -3918,9 +3983,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var lockedSwipeX = 0f
|
||||
private var lockedSwipeY = 0f
|
||||
private var dismissTouch = false
|
||||
private var sessionTouchDown = false
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
sessionTouchDown = true
|
||||
dismissTouch = false
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> sessionTouchDown = false
|
||||
}
|
||||
if (drawerOpen && drawerDocked && event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
drawerLayerOpen = false
|
||||
drawerOpenState = false
|
||||
@@ -3932,7 +4005,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) dismissTouch = false
|
||||
return true
|
||||
}
|
||||
val uiBlocking = drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
|
||||
val uiBlocking = drawerOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && !uiBlocking && routedTouchMode != SessionPrefs.TOUCH_GESTURES) {
|
||||
scrollTouchGesture = scrollTouchMode()
|
||||
scrollRestX = 0f
|
||||
@@ -3960,7 +4033,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
com.droiddeck.launcher.input.TouchRouter.Route.SCROLL -> scrollTouch.onTouch(event)
|
||||
else -> {
|
||||
if (com.droiddeck.launcher.input.TouchRouter.steamKeyboardZone(snapshot)) {
|
||||
val wasActive = threeFingerTap.active
|
||||
val wasActive = threeFingerTap.active && event.actionMasked != MotionEvent.ACTION_DOWN
|
||||
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
|
||||
if (threeFingerTap.active && !wasActive) {
|
||||
if (route == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD) touchpad.cancel()
|
||||
@@ -4036,9 +4109,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) lockedSwipeUsed = false
|
||||
if (lockedSwipeUsed && (event.actionMasked != MotionEvent.ACTION_MOVE || !programLocksPointer())) return true
|
||||
if (programLocksPointer() && event.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
if (lockedSwipeUsed) directTouchHold.cancelPendingHold() else directTouchHold.cancelTouch()
|
||||
lockedSwipeUsed = true
|
||||
directTouchHold.cancelPendingHold()
|
||||
val scale = sceneScale(rect)
|
||||
sendSceneDelta((event.x - swipeX) * scale, (event.y - swipeY) * scale)
|
||||
return true
|
||||
@@ -4155,6 +4229,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
applyPlatformPointer(appUiOwnsPointer())
|
||||
}
|
||||
|
||||
private fun onlyPcKeyboardOpen(): Boolean =
|
||||
pcKeyboardOpen && !drawerOpen && !achievementsOpen && !inputSettingsOpen && !pipUi && !glassesOutputActive && !touchEditorOpen && !wowChatOnTop
|
||||
|
||||
private fun appUiOwnsPointer(): Boolean =
|
||||
drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
|
||||
|
||||
@@ -4245,7 +4322,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
updateOnScreenPad()
|
||||
val c = onScreenControls
|
||||
glassesSlot = when {
|
||||
c == null || c.visibility != View.VISIBLE -> 0
|
||||
c == null || c.visibility != View.VISIBLE || c.isPinnedOnly -> 0
|
||||
c.isButtonsOnly -> -1
|
||||
primaryInputLayout?.let { l -> l.elements.any { it.quickButton == InputCommand.KEYBOARD && !l.style.hides(it) } }
|
||||
?: (controllerSettings?.keyboardButton != false) -> 2
|
||||
@@ -4260,12 +4337,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
fun showPinned() {
|
||||
controls.setPinnedOnly(true)
|
||||
val pinned = controls.hasPinned && !pipUi && !inputSettingsOpen
|
||||
if (!pinned) controls.releaseAll()
|
||||
controls.visibility = if (pinned) View.VISIBLE else View.GONE
|
||||
}
|
||||
if (pipUi || unfoldedPanel != null || pcKeyboardOpen || inputSettingsOpen ||
|
||||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
|
||||
onScreenButtonsVisible = false
|
||||
hideBottomControls()
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
showPinned()
|
||||
steamTouchControls?.let { it.cancelEditing(); it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
applyTouchRouting()
|
||||
@@ -4298,7 +4380,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val drawn = show && !keyboardCoversPad()
|
||||
if (steam != null) {
|
||||
hideBottomControls()
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
showPinned()
|
||||
if (!drawn) { steam.cancelEditing(); steam.releaseAll() }
|
||||
steam.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
|
||||
@@ -4307,7 +4389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
val bottomRoot = bottomGame?.surfaceView?.parent as? android.view.ViewGroup
|
||||
if (drawn && bottomRoot != null) {
|
||||
if (bottomRoot != null && (drawn || controls.hasPinned)) {
|
||||
controls.releaseAll(); controls.visibility = View.GONE
|
||||
var bottom = bottomInputControls
|
||||
if (bottom?.parent !== bottomRoot) {
|
||||
@@ -4320,13 +4402,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
bottom.updateLayout(primaryInputLayout)
|
||||
bottom.setButtonsOnly(buttonsOnly)
|
||||
bottom.setPinnedOnly(!drawn)
|
||||
bottom.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
hideBottomControls()
|
||||
if (drawn == (controls.visibility == View.VISIBLE)) return
|
||||
if (!drawn) controls.releaseAll()
|
||||
controls.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
val wasDrawn = controls.visibility == View.VISIBLE && !controls.isPinnedOnly
|
||||
if (!drawn) showPinned() else { controls.setPinnedOnly(false); controls.visibility = View.VISIBLE }
|
||||
if (drawn == wasDrawn) return
|
||||
Log.i(TAG, "on-screen controls " + when {
|
||||
!drawn -> "hidden"
|
||||
buttonsOnly -> "Steam + QAM"
|
||||
@@ -5069,7 +5152,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.KEYBOARD_TRACKPAD.label), listOf(keyboardExtra)))
|
||||
SecondScreenMode.DECK_CONTROLS -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.DECK_CONTROLS.label),
|
||||
tabs = com.droiddeck.launcher.ui.deckPanelTabs(this@SessionActivity, SessionPrefs.deckPanel(this@SessionActivity), ::selectDeckPanel)))
|
||||
else -> Unit
|
||||
else -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(secondScreenMode.label)))
|
||||
}
|
||||
androidx.compose.runtime.key(profileLayout) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
@@ -5286,6 +5369,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (AgentGuest.readyListener === agentReadyListener) AgentGuest.readyListener = null
|
||||
com.droiddeck.launcher.input.HomeButtonShortcuts.detach(this)
|
||||
relativeMouseRequests.shutdownNow()
|
||||
backQamInput.cancel()
|
||||
@@ -5390,13 +5474,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
const val EXTRA_RETURN_HOME = "returnHome"
|
||||
const val EXTRA_LINK_TASK = "linkTask"
|
||||
const val EXTRA_STEAMVR = "steamVr"
|
||||
const val EXTRA_PLAYED_GAME = "com.droiddeck.launcher.PLAYED_GAME"
|
||||
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val TEXT_FIELD_TAP_MS = 1_000L
|
||||
private const val PAUSED_FRAME_MAX_EDGE = 1920f
|
||||
private const val PAUSED_FRAME_REDRAW_MS = 150L
|
||||
private const val SETTINGS_WATCH_MS = 1_000L
|
||||
private const val SETTINGS_WATCH_CHECKS = 600
|
||||
private const val BOTTOM_RECOVERY_MS = 1_000L
|
||||
private const val BOTTOM_RECOVERY_TRIES = 3
|
||||
private const val TAG = "SessionActivity"
|
||||
@@ -5438,6 +5521,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private const val ALIGN_CENTER = 0
|
||||
private const val ALIGN_PICTURE_TOP = 3
|
||||
private const val ALIGN_PICTURE_BOTTOM = 4
|
||||
private const val ALIGN_PICTURE_LEFT = 5
|
||||
private const val ALIGN_PICTURE_RIGHT = 6
|
||||
private val RUNTIME_LINES = PackageLines(
|
||||
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
|
||||
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The agent bridge for devices whose shell has no `content` command: the same calls as
|
||||
* [AgentBridgeProvider], answered in a file under the app's external files folder.
|
||||
*/
|
||||
class AgentCallReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val method = intent.getStringExtra("method") ?: return
|
||||
val id = intent.getStringExtra("id")?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: return
|
||||
val arg = intent.getStringExtra("arg")
|
||||
val request = intent.getStringExtra(AgentBridgeProvider.EXTRA_REQUEST)
|
||||
val pending = goAsync()
|
||||
Thread({
|
||||
val json = try {
|
||||
val extras = Bundle().apply { putString(AgentBridgeProvider.EXTRA_REQUEST, request) }
|
||||
context.contentResolver.call("${context.packageName}.agent", method, arg, extras)
|
||||
?.getString(AgentBridgeProvider.RESULT_JSON) ?: """{"ok":false,"error":{"code":"NO_RESPONSE","message":"no response"}}"""
|
||||
} catch (e: Exception) {
|
||||
org.json.JSONObject().put("ok", false)
|
||||
.put("error", org.json.JSONObject().put("code", "COMMAND_FAILED").put("message", e.message ?: e.javaClass.simpleName)).toString()
|
||||
}
|
||||
try {
|
||||
val dir = File(context.getExternalFilesDir(null), "agent-calls").apply { mkdirs() }
|
||||
File(dir, "$id.json.part").writeText(json)
|
||||
File(dir, "$id.json.part").renameTo(File(dir, "$id.json"))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("AgentCallReceiver", "could not write the answer to $method: ${e.message}")
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}, "agent-call-$id").start()
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ object AgentGuest {
|
||||
var keyboardDismissals by mutableIntStateOf(0)
|
||||
private set
|
||||
@Volatile private var keyboardRoute = false
|
||||
@Volatile var readyListener: (() -> Unit)? = null
|
||||
@Volatile private var routeDir: File? = null
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
@@ -59,6 +60,7 @@ object AgentGuest {
|
||||
when (path) {
|
||||
"hello.json" -> readJson(File(dir, path))?.let {
|
||||
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
|
||||
readyListener?.invoke()
|
||||
}
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
|
||||
|
||||
@@ -18,9 +18,10 @@ object Library {
|
||||
val source: String = SOURCE_STEAM,
|
||||
val storeId: String? = null,
|
||||
val logo: File? = null,
|
||||
val installedAt: Long = 0L,
|
||||
) {
|
||||
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
|
||||
@@ -31,6 +32,7 @@ object Library {
|
||||
}
|
||||
}
|
||||
const val ADDED = "added"
|
||||
private const val PLAYED = "added_games_played"
|
||||
const val EPIC_LAUNCHER = "epic-launcher"
|
||||
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
|
||||
const val BATTLENET_LAUNCHER = "battlenet-launcher"
|
||||
@@ -221,6 +223,18 @@ object Library {
|
||||
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, game: SteamGame) {
|
||||
if (game.library == ADDED) recordPlayed(context, game.gameIdString)
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, gameId: String) {
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).edit()
|
||||
.putLong(gameId, System.currentTimeMillis() / 1000).apply()
|
||||
}
|
||||
|
||||
internal fun recordedPlay(context: Context, gameId: Long): Long =
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).getLong(java.lang.Long.toUnsignedString(gameId), 0L)
|
||||
|
||||
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptyMap()
|
||||
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
|
||||
@@ -249,12 +263,13 @@ object Library {
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
|
||||
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
|
||||
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
|
||||
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
logo = art.logo,
|
||||
installedAt = g.folder.lastModified(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ object LibraryCache {
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
logo = o.file("logo"),
|
||||
installedAt = o.optLong("installedAt"),
|
||||
)
|
||||
} to x64
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
|
||||
@@ -44,7 +45,7 @@ object LibraryCache {
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
.put("source", g.source).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -159,6 +159,12 @@ object DriverBundle {
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
fun selectDisplay(context: Context, id: String) {
|
||||
val left = active(context)
|
||||
SessionPrefs.setAndroidDriver(context, id)
|
||||
if (left != null && left.androidId != id) SessionPrefs.setLinuxDriver(context, "")
|
||||
}
|
||||
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
|
||||
@@ -191,6 +191,7 @@ public final class SystemVulkanDriver {
|
||||
|
||||
/** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */
|
||||
public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException {
|
||||
if (!AddonPack.POWERVR.installed(context)) return;
|
||||
pruneUnused(runtimeDirectory(context), guestsStopped);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlin.math.roundToInt
|
||||
internal class FrameStats {
|
||||
private val graph = FloatArray(GRAPH_FRAMES)
|
||||
private var graphHead = 0
|
||||
private var graphFrames = 0L
|
||||
private var lastPresentNs = 0L
|
||||
private var lastFpsUpdateNs = 0L
|
||||
private var framesSinceUpdate = 0
|
||||
@@ -38,6 +39,7 @@ internal class FrameStats {
|
||||
if (lastPresentNs != 0L) {
|
||||
graph[graphHead] = frametimeNs / 1e6f
|
||||
graphHead = (graphHead + 1) % GRAPH_FRAMES
|
||||
graphFrames++
|
||||
}
|
||||
val elapsed = timeNs - lastFpsUpdateNs
|
||||
if (elapsed >= FPS_PERIOD_NS) {
|
||||
@@ -68,6 +70,7 @@ internal class FrameStats {
|
||||
fun reset() {
|
||||
graph.fill(0f)
|
||||
graphHead = 0
|
||||
graphFrames = 0L
|
||||
lastPresentNs = 0L
|
||||
lastFpsUpdateNs = 0L
|
||||
framesSinceUpdate = 0
|
||||
@@ -86,8 +89,9 @@ internal class FrameStats {
|
||||
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
|
||||
|
||||
@Synchronized
|
||||
fun graph(out: FloatArray) {
|
||||
fun graph(out: FloatArray): Long {
|
||||
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
|
||||
return graphFrames
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
@@ -33,6 +34,8 @@ object BottomScreenGuests {
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun current(arrivedAt: Long, ownShownAt: Long?): Boolean = ownShownAt == null || arrivedAt >= ownShownAt
|
||||
|
||||
internal fun ignorable(type: Int): Boolean = type == AccessibilityWindowInfo.TYPE_INPUT_METHOD ||
|
||||
type == AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY ||
|
||||
type == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER ||
|
||||
@@ -43,7 +46,7 @@ object BottomScreenGuests {
|
||||
}
|
||||
|
||||
class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private class Arrival(val pkg: String)
|
||||
private class Arrival(val pkg: String, val at: Long)
|
||||
|
||||
private val thread = HandlerThread("bottom-screen").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
@@ -52,6 +55,8 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private var homes: Set<String> = emptySet()
|
||||
private var keyboards: Set<String> = emptySet()
|
||||
private var holders: Map<Int, String?> = emptyMap()
|
||||
private var ownWindows: Map<Int, Set<Int>> = emptyMap()
|
||||
private val ownShownAt = mutableMapOf<Int, Long>()
|
||||
private val check = Runnable { evaluate() }
|
||||
|
||||
fun start() {
|
||||
@@ -72,7 +77,7 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
|
||||
val pkg = event.packageName?.toString() ?: return
|
||||
if (event.displayId == Display.DEFAULT_DISPLAY || pkg == own || pkg == SYSTEM_UI) return
|
||||
arrivals[event.displayId] = Arrival(pkg)
|
||||
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
|
||||
else -> return
|
||||
@@ -97,19 +102,25 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
val all = runCatching { service.windowsOnAllDisplays }.getOrNull() ?: return
|
||||
val displays = service.getSystemService(DisplayManager::class.java) ?: return
|
||||
val taken = mutableMapOf<Int, String?>()
|
||||
val shown = mutableMapOf<Int, Set<Int>>()
|
||||
val now = SystemClock.uptimeMillis()
|
||||
for (i in 0 until all.size()) {
|
||||
val id = all.keyAt(i)
|
||||
if (id == Display.DEFAULT_DISPLAY) continue
|
||||
val display = displays.getDisplay(id)?.takeIf(DualScreen::builtIn) ?: continue
|
||||
val windows = all.valueAt(i)
|
||||
val topPkg = windows.asSequence()
|
||||
.filter { !BottomScreenGuests.ignorable(it.type) && cover(it, display) >= BottomScreenGuests.MIN_COVER }
|
||||
.sortedByDescending { it.layer }
|
||||
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]
|
||||
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
|
||||
.map { it to packageOf(it) }
|
||||
val ours = windows.filter { it.second == own }.map { it.first.id }.toSet()
|
||||
if (!ownWindows[id].orEmpty().containsAll(ours)) ownShownAt[id] = now
|
||||
shown[id] = ours
|
||||
val topPkg = windows.firstNotNullOfOrNull { it.second?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]?.takeIf { BottomScreenGuests.current(it.at, ownShownAt[id]) }
|
||||
seen[id]?.takeIf { topPkg != null || arrival == null }?.let { arrivals.remove(id, it) }
|
||||
BottomScreenGuests.holder(topPkg, arrival?.pkg?.takeUnless { it in keyboards }, homes)?.let { taken[id] = it }
|
||||
}
|
||||
ownWindows = shown
|
||||
if (current !== this) return
|
||||
if (taken != holders) Log.i(TAG, "bottom screen holders: $taken")
|
||||
holders = taken
|
||||
|
||||
@@ -73,7 +73,7 @@ object ControllerPrefs {
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
joyConVr = joyConVr(context),
|
||||
snap = p.getBoolean("snap", true),
|
||||
snap = snap(context),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
deadZone = deadZone(context),
|
||||
@@ -87,11 +87,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putString("map.$id", target).apply()
|
||||
}
|
||||
|
||||
fun resetMapping(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
|
||||
fun setTint(context: Context, tint: Int) {
|
||||
prefs(context).edit().putInt("tint", tint).apply()
|
||||
}
|
||||
@@ -129,10 +124,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("steamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setQamButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("qamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setKeyboardButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("keyboardButton", on).apply()
|
||||
}
|
||||
@@ -147,6 +138,8 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("joyConVr", on).apply()
|
||||
}
|
||||
|
||||
fun snap(context: Context): Boolean = prefs(context).getBoolean("snap", true)
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
@@ -172,9 +165,4 @@ object ControllerPrefs {
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("rumble", true).apply()
|
||||
}
|
||||
|
||||
fun resetAllLayouts(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,14 @@ class DirectTouchHold(
|
||||
handler.removeCallbacks(hold)
|
||||
}
|
||||
|
||||
fun cancelTouch() {
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(lift)
|
||||
liftPending = false
|
||||
if (forwarded) touch.cancel()
|
||||
forwarded = false
|
||||
}
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
if (liftPending) {
|
||||
|
||||
@@ -4,12 +4,13 @@ import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
object InputLibraries {
|
||||
private const val VERSION = 12
|
||||
private const val VERSION = 14
|
||||
private const val KEY = "library"
|
||||
private const val RECOVERY = "library-recovery."
|
||||
const val OVERLAY = "builtin.overlay"
|
||||
@@ -30,6 +31,8 @@ object InputLibraries {
|
||||
if (version < 11) library = neutralStockLayouts(library)
|
||||
if (version < 10) library = restyleGamepads(context, library)
|
||||
if (version < 12) library = barStockLayouts(library)
|
||||
if (version < 13) library = LegacyScreenKeyMigration.pin(library)
|
||||
if (version < 14) library = deckGamepadSurfaces(context, library)
|
||||
if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) {
|
||||
val legacy = LegacyScreenKeyMigration.snapshot(context)
|
||||
if (legacy != null) {
|
||||
@@ -263,6 +266,15 @@ object InputLibraries {
|
||||
return addStockLayouts(result)
|
||||
}
|
||||
|
||||
private fun deckGamepadSurfaces(context: Context, library: InputLibrary): InputLibrary {
|
||||
val leftPad = SessionPrefs.gamepadSurface(context, right = false) == SessionPrefs.SURFACE_TRACKPAD
|
||||
val rightStick = SessionPrefs.gamepadSurface(context, right = true) == SessionPrefs.SURFACE_STICK
|
||||
if (!leftPad && !rightStick) return library
|
||||
val saved = library.layouts.firstOrNull { it.id == DeckPanel.GAMEPAD.layout } ?: return library
|
||||
if (saved.elements != InputStockLayouts.gamepad(leftPad = false, rightStick = false)) return library
|
||||
return library.withLayout(saved.copy(elements = InputStockLayouts.gamepad(leftPad, rightStick))).validate()
|
||||
}
|
||||
|
||||
private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String {
|
||||
val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8)
|
||||
val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed)
|
||||
@@ -292,7 +304,7 @@ object InputLibraries {
|
||||
for (e in layout.elements) elements.put(JSONObject().put("id", e.id).put("kind", e.kind.name)
|
||||
.put("control", e.control.id).put("name", e.name).put("x", e.x.toDouble()).put("y", e.y.toDouble())
|
||||
.put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image)
|
||||
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel))
|
||||
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel).put("alwaysShown", e.alwaysShown))
|
||||
layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements)
|
||||
.put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble())
|
||||
.put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL))
|
||||
@@ -333,7 +345,7 @@ object InputLibraries {
|
||||
e.getDouble("y").toFloat(), e.getDouble("width").toFloat(), e.getDouble("height").toFloat(),
|
||||
e.getString("id"), InputElementKind.valueOf(e.getString("kind")), e.getString("name"),
|
||||
e.optBoolean("floating", true), e.optBoolean("hideWhenIdle", false), e.optBoolean("mouseGestures", false), e.optBoolean("toggle", false), e.optInt("icon", 0), e.optString("image", ""),
|
||||
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true))
|
||||
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true), e.optBoolean("alwaysShown", false))
|
||||
}
|
||||
SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")),
|
||||
InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100),
|
||||
|
||||
@@ -45,6 +45,14 @@ internal object InputStockLayouts {
|
||||
InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL),
|
||||
InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL))
|
||||
}
|
||||
fun gamepad(leftPad: Boolean, rightStick: Boolean) = gamepad().map {
|
||||
when {
|
||||
leftPad && it.control == InputControl.LS -> InputSurfaceElement(InputControl.LEFT_PAD, it.x, it.y, it.width, it.height)
|
||||
rightStick && it.control == InputControl.RIGHT_PAD -> InputSurfaceElement(InputControl.RS, it.x, it.y, it.width, it.height)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
|
||||
fun layouts() = listOf(
|
||||
SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf(
|
||||
|
||||
@@ -34,6 +34,7 @@ data class InputSurfaceElement(
|
||||
val opacity: Float = 1f,
|
||||
val buttonShape: InputButtonShape = InputButtonShape.ROUNDED,
|
||||
val showLabel: Boolean = true,
|
||||
val alwaysShown: Boolean = false,
|
||||
) {
|
||||
val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole
|
||||
private val gestureRight = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(273)) else null
|
||||
private val gestureMiddle = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(274)) else null
|
||||
val mouseGestures get() = element.mouseGestures && route?.action is InputAction.Pointer
|
||||
val directions get() = route?.action as? InputAction.Directions
|
||||
|
||||
fun gestureContact(touching: Boolean) { if (!closed) route?.update(0f, 0f, touching) }
|
||||
fun pointerDelta(dx: Float, dy: Float) {
|
||||
|
||||
@@ -225,7 +225,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
|
||||
)
|
||||
}
|
||||
|
||||
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean): List<InputSurfaceElement> {
|
||||
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean, left: InputControl = InputControl.LS,
|
||||
right: InputControl = InputControl.RS): List<InputSurfaceElement> {
|
||||
val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait)
|
||||
val placed = mutableListOf<InputSurfaceElement>()
|
||||
fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) {
|
||||
@@ -243,9 +244,9 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
|
||||
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
|
||||
}
|
||||
}
|
||||
box(InputControl.LS, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
|
||||
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
|
||||
box(InputControl.RS, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
|
||||
box(right, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL)
|
||||
@@ -254,7 +255,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
|
||||
box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL)
|
||||
box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL)
|
||||
if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) }
|
||||
return withGeometry(stored, placed) + placed.filter { p -> GRIPS.any { it.first == p.control } && stored.none { it.id == p.id } }
|
||||
val surfaces = listOf(left, right).filter { it.isTrackpad }
|
||||
return withGeometry(stored, placed) + placed.filter { p -> (GRIPS.any { it.first == p.control } || p.control in surfaces) && stored.none { it.id == p.id } }
|
||||
}
|
||||
|
||||
private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) =
|
||||
|
||||
@@ -40,7 +40,7 @@ internal object LegacyScreenKeyMigration {
|
||||
if (result.profiles.any { it.id == id }) continue
|
||||
val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density)
|
||||
if (converted.isEmpty()) continue
|
||||
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first }, style = style, screen = InputLayoutScreen.MAIN,
|
||||
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first.copy(alwaysShown = true) }, style = style, screen = InputLayoutScreen.MAIN,
|
||||
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second })
|
||||
val base = library.profile(library.steam)
|
||||
@@ -68,6 +68,17 @@ internal object LegacyScreenKeyMigration {
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
fun pin(library: InputLibrary): InputLibrary {
|
||||
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" }?.elements.orEmpty()
|
||||
return library.copy(layouts = library.layouts.map { layout ->
|
||||
val imported = layout.id.startsWith("migrated.screenkeys.")
|
||||
layout.copy(elements = layout.elements.map { e ->
|
||||
val key = e.id.startsWith("imported:") && (imported || keys.any { it.id == e.id && it.kind == e.kind && it.name == e.name })
|
||||
if (key) e.copy(alwaysShown = true) else e
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
|
||||
convert(context, parse(raw), width, height, density)
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
|
||||
private set
|
||||
var isQuickHidden = false
|
||||
private set
|
||||
var isPinnedOnly = false
|
||||
private set
|
||||
val hasPinned get() = layout?.elements?.any { it.alwaysShown } == true
|
||||
var onTouchMiss: ((MotionEvent) -> Unit)?
|
||||
get() = surface.onTouchMiss
|
||||
set(value) { surface.onTouchMiss = value }
|
||||
@@ -61,6 +64,13 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
|
||||
render()
|
||||
}
|
||||
|
||||
fun setPinnedOnly(enabled: Boolean) {
|
||||
if (isPinnedOnly == enabled) return
|
||||
releaseAll()
|
||||
isPinnedOnly = enabled
|
||||
render()
|
||||
}
|
||||
|
||||
private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean)
|
||||
|
||||
private fun placed(): List<InputSurfaceElement> {
|
||||
@@ -72,12 +82,16 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
|
||||
|
||||
private fun render() {
|
||||
val elements = placed()
|
||||
surface.updateCustomLayout(if (isQuickHidden) elements.filter { it.id == "builtin:keyboard" } else elements)
|
||||
surface.updateCustomLayout(when {
|
||||
isPinnedOnly -> elements.filter { it.alwaysShown }
|
||||
isQuickHidden -> elements.filter { it.id == "builtin:keyboard" || it.alwaysShown }
|
||||
else -> elements
|
||||
})
|
||||
val style = layout?.style ?: InputSurfaceStyle()
|
||||
surface.updateStyle(style)
|
||||
quickToggle.tint = style.tint
|
||||
quickToggle.alpha = style.opacity / 100f
|
||||
quickToggle.visibility = if (isButtonsOnly || layout == null || dock != null) GONE else VISIBLE
|
||||
quickToggle.visibility = if (isButtonsOnly || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
|
||||
quickToggle.invalidate()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,18 @@ import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object OnScreenLayout {
|
||||
data class Dock(val portrait: Boolean, val grips: Boolean)
|
||||
data class Dock(val portrait: Boolean, val grips: Boolean, val left: InputControl = InputControl.LS, val right: InputControl = InputControl.RS) {
|
||||
companion object {
|
||||
fun deck(portrait: Boolean, gamepad: SavedInputLayout?): Dock {
|
||||
fun side(right: Boolean) = gamepad?.elements?.firstOrNull {
|
||||
(it.kind == InputElementKind.STICK || it.kind == InputElementKind.TRACKPAD) && (it.x + it.width / 2f >= .5f) == right
|
||||
}?.kind
|
||||
val left = if (side(false) == InputElementKind.TRACKPAD) InputControl.LEFT_PAD else InputControl.LS
|
||||
val right = if (side(true) == InputElementKind.STICK) InputControl.RS else InputControl.RIGHT_PAD
|
||||
return Dock(portrait, true, left, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TOLERANCE = .01f
|
||||
private const val KEYBOARD_ID = "builtin:keyboard"
|
||||
@@ -17,13 +28,13 @@ object OnScreenLayout {
|
||||
val stored = layout?.elements ?: return emptyList()
|
||||
if (width <= 0 || height <= 0) return stored
|
||||
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
|
||||
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored)
|
||||
val (reference, generated) = reference(context, layout, width, height)
|
||||
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + fit(stored.filter { it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
|
||||
val others = fit(stored.filterNot { generated && isGamepad(it) }, reference.first, reference.second, width, height, safe)
|
||||
val others = fit(stored.filterNot { generated && isGamepad(it) || dock != null && it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val placed = when {
|
||||
gamepad.isEmpty() -> emptyList()
|
||||
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips)
|
||||
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips, dock.left, dock.right)
|
||||
else -> generator.regenerate(gamepad)
|
||||
}
|
||||
val byId = (placed + others).associateBy { it.id }
|
||||
|
||||
@@ -52,4 +52,7 @@ object SecondScreenDisplays {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
|
||||
(listOf(selected) + available).distinct().filterNot(taken)
|
||||
}
|
||||
@@ -213,6 +213,9 @@ data class TouchGestureConfig(
|
||||
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
|
||||
}
|
||||
|
||||
fun gamescopeRelativeMouse(mode: Int, mouseAttached: Boolean, touchpad: Boolean): Int =
|
||||
if (mode == RELATIVE_MOUSE_AUTO && !mouseAttached && !touchpad) RELATIVE_MOUSE_OFF else mode
|
||||
|
||||
private fun normalizeRelativeMouseMode(mode: Int): Int {
|
||||
return if (mode in RELATIVE_MOUSE_AUTO..RELATIVE_MOUSE_OFF) mode else RELATIVE_MOUSE_AUTO
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ object DesktopCatalog {
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
|
||||
const val PROTON_SEED_ID = "proton-arm64"
|
||||
const val APPIMAGE_EXEC = "/usr/local/bin/droiddeck-appimage-exec"
|
||||
|
||||
class Entry(
|
||||
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
|
||||
@@ -216,7 +217,7 @@ object DesktopCatalog {
|
||||
target.setExecutable(true, false)
|
||||
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
|
||||
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Exec=$APPIMAGE_EXEC /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -21,9 +21,11 @@ object LegacyNames {
|
||||
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
|
||||
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
|
||||
)
|
||||
private const val MENU_DIR = "usr/local/share/applications"
|
||||
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
|
||||
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
|
||||
private val MENU_REWRITES = listOf(
|
||||
Triple("usr/local/share/applications", "/usr/local/bin/bannerlator-", "/usr/local/bin/droiddeck-"),
|
||||
Triple("usr/share/applications", "Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/",
|
||||
"Exec=${DesktopCatalog.APPIMAGE_EXEC} /opt/appimages/"),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
@@ -95,14 +97,16 @@ object LegacyNames {
|
||||
}
|
||||
|
||||
private fun rewriteMenuEntries(root: File) {
|
||||
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(OLD_BIN)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
for ((dir, old, new) in MENU_REWRITES) {
|
||||
val entries = File(root, dir).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: continue
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(old)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(old, new)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,12 @@ public final class LinuxRuntimeInstaller {
|
||||
private LinuxRuntimeInstaller() {}
|
||||
|
||||
public static String installedVersion(Context context) {
|
||||
return LinuxRuntime.isInstalled(context) ? rootfsVersion(context) : null;
|
||||
}
|
||||
|
||||
private static String rootfsVersion(Context context) {
|
||||
File marker = new File(LinuxRuntime.rootDir(context), VERSION_FILE);
|
||||
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
|
||||
if (!marker.isFile()) return null;
|
||||
String v = FileUtils.readString(marker);
|
||||
return v == null ? null : v.trim();
|
||||
}
|
||||
@@ -144,6 +148,10 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
};
|
||||
boolean desktop = DesktopCatalog.INSTANCE.desktopInstalled(app);
|
||||
if (packsOnly(app, release, progress)) {
|
||||
job.ok = true;
|
||||
return true;
|
||||
}
|
||||
job.ok = installOnce(app, release, progress);
|
||||
if (job.ok && desktop) keepDesktop(app, progress);
|
||||
return job.ok;
|
||||
@@ -283,6 +291,31 @@ public final class LinuxRuntimeInstaller {
|
||||
for (File file : files) file.delete();
|
||||
}
|
||||
|
||||
private static kotlin.jvm.functions.Function1<AddonPack.State, kotlin.Unit> packProgress(ProgressListener listener, String stage) {
|
||||
return state -> {
|
||||
if (listener != null && state instanceof AddonPack.State.Downloading) {
|
||||
listener.onProgress(Step.DOWNLOADING, stage, Math.round(((AddonPack.State.Downloading) state).getProgress() * 100f));
|
||||
}
|
||||
return kotlin.Unit.INSTANCE;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean packsOnly(Context context, Release release, ProgressListener listener) {
|
||||
if (!LinuxRuntime.hasRootfs(context) || LinuxRuntime.isInstalled(context) || !release.version.equals(rootfsVersion(context))) return false;
|
||||
File root = LinuxRuntime.rootDir(context);
|
||||
try {
|
||||
recoverInterruptedSwap(root, new File(root.getParentFile(), LinuxRuntime.DIR + ".new"), new File(root.getParentFile(), LinuxRuntime.DIR + ".old"));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "packs only: " + e);
|
||||
return false;
|
||||
}
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
return AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& LinuxRuntime.isInstalled(context);
|
||||
}
|
||||
|
||||
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
|
||||
File archive = new File(context.getCacheDir(), archiveName(release));
|
||||
boolean keepPartial = false;
|
||||
@@ -294,10 +327,10 @@ 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 (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
|
||||
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& !SessionPrefs.allowStockProot(context))
|
||||
throw new IOException("Cannot download proot");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) 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,
|
||||
|
||||
@@ -35,6 +35,7 @@ object UserApps {
|
||||
private const val GITHUB_REPO = "github-repo"
|
||||
private const val GITHUB_TAG = "github-tag"
|
||||
private const val GITHUB_PUBLISHED = "github-published"
|
||||
private const val GITHUB_ASSET = "github-asset"
|
||||
|
||||
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
|
||||
|
||||
@@ -56,7 +57,7 @@ object UserApps {
|
||||
sealed class Source {
|
||||
class Script(val file: File) : Source()
|
||||
class AppImage(val file: File) : Source()
|
||||
class GitHub(val repo: String) : Source()
|
||||
class GitHub(val repo: String, val asset: String? = null) : Source()
|
||||
class Flatpak(val id: String) : Source()
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
@@ -113,7 +114,7 @@ object UserApps {
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, s.asset, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
}
|
||||
@@ -305,14 +306,26 @@ object UserApps {
|
||||
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
|
||||
}
|
||||
|
||||
internal fun pickAsset(names: List<String>): String? {
|
||||
fun githubAsset(input: String): String? =
|
||||
Regex("github\\.com/[\\w.-]+/[\\w.-]+/releases/(?:download/[^/]+|latest/download)/([^/?#]+\\.appimage)(?:[?#].*)?$", RegexOption.IGNORE_CASE)
|
||||
.find(input.trim())?.groupValues?.get(1)?.let { runCatching { java.net.URLDecoder.decode(it, "UTF-8") }.getOrNull() }
|
||||
|
||||
private fun assetVariant(name: String): Set<String> =
|
||||
name.lowercase().replace(Regex("x86[_-]64|amd64"), "x64").replace(Regex("x86[_-]32|i[36]86"), "x32")
|
||||
.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() && !Regex("[0-9a-f]{7,}|v?[0-9]+").matches(it) }.toSet()
|
||||
|
||||
internal fun pickAsset(names: List<String>, wanted: String? = null): String? {
|
||||
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
|
||||
if (wanted != null) {
|
||||
images.firstOrNull { it == wanted }?.let { return it }
|
||||
images.firstOrNull { assetVariant(it) == assetVariant(wanted) }?.let { return it }
|
||||
}
|
||||
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
}
|
||||
|
||||
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
|
||||
private fun latestRelease(context: Context, repo: String, wanted: String? = null): Pair<Release?, String?> {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
|
||||
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
|
||||
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
|
||||
@@ -321,7 +334,7 @@ object UserApps {
|
||||
if (r.optBoolean("draft")) return@mapNotNull null
|
||||
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
|
||||
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } }
|
||||
pickAsset(byName.keys.toList())?.let { asset ->
|
||||
pickAsset(byName.keys.toList(), wanted)?.let { asset ->
|
||||
r.optBoolean("prerelease") to byName.getValue(asset).let { a ->
|
||||
Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"),
|
||||
Hashes.githubSha256(a.optString("digest")))
|
||||
@@ -344,11 +357,11 @@ object UserApps {
|
||||
}
|
||||
|
||||
private fun githubMeta(repo: String, release: Release) =
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published, GITHUB_ASSET to release.asset)
|
||||
|
||||
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
private fun addFromGitHub(context: Context, repo: String, asset: String?, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
val (release, problem) = latestRelease(context, repo, asset)
|
||||
if (release == null) return problem
|
||||
return withDownload(context, release, onProgress) { file ->
|
||||
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
|
||||
@@ -381,9 +394,10 @@ object UserApps {
|
||||
|
||||
fun checkUpdate(context: Context, app: App): UpdateCheck {
|
||||
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
val dir = AppImageManager.dir(context, app.key.substringAfter(':'))
|
||||
val (release, problem) = latestRelease(context, repo, FileUtils.readString(File(dir, GITHUB_ASSET))?.trim()?.takeIf { it.isNotEmpty() })
|
||||
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
|
||||
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val published = FileUtils.readString(File(dir, GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
|
||||
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long)
|
||||
|
||||
object BottomNotifications {
|
||||
val items = mutableStateListOf<BottomNotification>()
|
||||
|
||||
fun granted(context: Context): Boolean =
|
||||
context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
|
||||
|
||||
internal fun keep(own: String, pkg: String, ongoing: Boolean, groupSummary: Boolean, title: String, text: String): Boolean =
|
||||
pkg != own && !ongoing && !groupSummary && (title.isNotBlank() || text.isNotBlank())
|
||||
|
||||
internal fun add(list: MutableList<BottomNotification>, item: BottomNotification) {
|
||||
list.removeAll { it.key == item.key }
|
||||
list.add(0, item)
|
||||
while (list.size > MAX_ITEMS) list.removeAt(list.lastIndex)
|
||||
}
|
||||
|
||||
fun clear() = items.clear()
|
||||
|
||||
internal fun post(context: Context, sbn: StatusBarNotification) {
|
||||
val n = sbn.notification ?: return
|
||||
val extras = n.extras
|
||||
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
|
||||
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
|
||||
if (!keep(context.packageName, sbn.packageName, sbn.isOngoing, (n.flags and Notification.FLAG_GROUP_SUMMARY) != 0, title, text)) return
|
||||
val app = runCatching {
|
||||
val pm = context.packageManager
|
||||
pm.getApplicationLabel(pm.getApplicationInfo(sbn.packageName, 0)).toString()
|
||||
}.getOrDefault(sbn.packageName)
|
||||
add(items, BottomNotification(sbn.key, app, title, text, sbn.postTime))
|
||||
}
|
||||
|
||||
internal fun remove(key: String) {
|
||||
items.removeAll { it.key == key }
|
||||
}
|
||||
|
||||
private const val MAX_ITEMS = 30
|
||||
}
|
||||
|
||||
class BottomNotificationListener : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
BottomNotifications.clear()
|
||||
runCatching { activeNotifications }.getOrNull()?.sortedBy { it.postTime }?.forEach { BottomNotifications.post(this, it) }
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) = BottomNotifications.post(this, sbn)
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) = BottomNotifications.remove(sbn.key)
|
||||
|
||||
override fun onListenerDisconnected() = BottomNotifications.clear()
|
||||
}
|
||||
@@ -68,8 +68,9 @@ object GameStorage {
|
||||
externalStoragePath: Boolean = isExternalStoragePath(path),
|
||||
): Boolean = when {
|
||||
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY -> true
|
||||
path.isDirectory -> true
|
||||
externalStoragePath -> false
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.parentFile?.isDirectory == true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,19 @@ object ProtonExtras {
|
||||
|
||||
internal fun installedIn(dir: File, tool: Tool): List<String> =
|
||||
dir.listFiles()
|
||||
?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile }
|
||||
?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && usable(it) }
|
||||
?.map { it.name }
|
||||
?.sortedWith { a, b -> compareVersions(a, b) }
|
||||
?: emptyList()
|
||||
|
||||
private fun usable(dir: File): Boolean {
|
||||
val manifest = runCatching { File(dir, "toolmanifest.vdf").readText() }.getOrNull() ?: return false
|
||||
val command = Regex("\\\"commandline\\\"\\s+\\\"/?([^\\\" ]+)").find(manifest)?.groupValues?.getOrNull(1) ?: return false
|
||||
val compat = runCatching { File(dir, "compatibilitytool.vdf").readText() }.getOrNull() ?: return false
|
||||
return Regex("\\\"from_oslist\\\"\\s+\\\"[^\\\"]*windows", RegexOption.IGNORE_CASE).containsMatchIn(compat) &&
|
||||
File(dir, command).isFile && File(dir, command).canExecute()
|
||||
}
|
||||
|
||||
private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64")
|
||||
private val NUMBER = Regex("\\d+")
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ object SessionDisplay {
|
||||
|
||||
const val UNFOLDED_MIN_DP = 600
|
||||
|
||||
fun unfoldedGameHeight(width: Int, height: Int): Int =
|
||||
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
|
||||
fun unfoldedGameHeight(width: Int, height: Int, tabRow: Int = 0): Int =
|
||||
(minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) - tabRow.coerceAtLeast(0)).coerceAtLeast(0) and 1.inv()
|
||||
|
||||
fun unfoldedControlsWanted(
|
||||
enabled: Boolean,
|
||||
|
||||
@@ -14,6 +14,7 @@ object SessionEvents {
|
||||
SessionPaths.take()
|
||||
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
|
||||
synchronized(SessionState) {
|
||||
SessionState.clearRelaunch()
|
||||
SessionState.phase = SessionPhase.PREPARING
|
||||
SessionState.mode = mode
|
||||
SessionState.sessionId = dir.name
|
||||
|
||||
@@ -40,8 +40,10 @@ object SessionFiles {
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
|
||||
"usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu",
|
||||
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
|
||||
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
|
||||
"usr/local/bin/droiddeck-wine-pe" to "usr/local/bin/droiddeck-wine-pe",
|
||||
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
|
||||
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
|
||||
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
|
||||
@@ -112,6 +114,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
|
||||
"usr/local/bin/droiddeck-flatpak-run" to "usr/local/bin/droiddeck-flatpak-run",
|
||||
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
|
||||
"usr/local/bin/droiddeck-appimage-exec" to "usr/local/bin/droiddeck-appimage-exec",
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal object SessionLoadingDecision {
|
||||
const val TIMEOUT_MS = 10_000L
|
||||
|
||||
fun shouldReturnToLauncher(startedAt: Long, now: Long, running: Boolean, phase: SessionPhase): Boolean =
|
||||
!running && phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED) && now - startedAt >= TIMEOUT_MS
|
||||
}
|
||||
@@ -558,11 +558,11 @@ object SessionPrefs {
|
||||
|
||||
fun picturePosition(context: Context): String =
|
||||
prefs(context).getString("picturePosition", "center")
|
||||
?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
|
||||
?.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center"
|
||||
|
||||
fun setPicturePosition(context: Context, position: String) {
|
||||
prefs(context).edit().putString("picturePosition",
|
||||
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
|
||||
position.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center").apply()
|
||||
}
|
||||
|
||||
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
|
||||
@@ -1555,10 +1555,6 @@ object SessionPrefs {
|
||||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||||
|
||||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||||
}
|
||||
|
||||
fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
|
||||
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
|
||||
|
||||
|
||||
@@ -1697,8 +1697,8 @@ class SessionService : Service() {
|
||||
.putExtra(EXTRA_STEAM_UI, ui)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url)
|
||||
SessionState.relaunch = next
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0) }
|
||||
SessionState.queueRelaunch(next)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0, relaunch = true) }
|
||||
}
|
||||
}
|
||||
watcher.startWatching()
|
||||
@@ -2013,7 +2013,7 @@ class SessionService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSession(status: Int, gen: Int? = null) {
|
||||
private fun stopSession(status: Int, gen: Int? = null, relaunch: Boolean = false) {
|
||||
if (gen != null && gen != sessionGen) {
|
||||
Log.i(TAG, "ignoring a stop from session $gen; session $sessionGen is current")
|
||||
return
|
||||
@@ -2024,6 +2024,7 @@ class SessionService : Service() {
|
||||
cancelQueuedSteamGame()
|
||||
gameExitWatchToken++
|
||||
if (!SessionState.running) return
|
||||
if (relaunch) SessionState.confirmRelaunchStop()
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
SessionState.running = false
|
||||
|
||||
@@ -73,7 +73,44 @@ object SessionState {
|
||||
@Volatile
|
||||
var steamClientArch: String? = null
|
||||
var steamVr by mutableStateOf(false)
|
||||
@Volatile
|
||||
var relaunch: android.content.Intent? = null
|
||||
@Volatile
|
||||
private var relaunchSessionId: String? = null
|
||||
@Volatile
|
||||
private var relaunchStopRequested = false
|
||||
|
||||
fun queueRelaunch(intent: android.content.Intent) {
|
||||
synchronized(this) {
|
||||
relaunch = intent
|
||||
relaunchSessionId = sessionId
|
||||
relaunchStopRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmRelaunchStop() {
|
||||
synchronized(this) {
|
||||
relaunchStopRequested = relaunch != null && relaunchSessionId == sessionId
|
||||
}
|
||||
}
|
||||
|
||||
fun takeRelaunch(): android.content.Intent? = synchronized(this) {
|
||||
val next = relaunch
|
||||
val valid = relaunchStopRequested && relaunchSessionId == sessionId
|
||||
relaunch = null
|
||||
relaunchSessionId = null
|
||||
relaunchStopRequested = false
|
||||
next.takeIf { valid }
|
||||
}
|
||||
|
||||
fun clearRelaunch() {
|
||||
synchronized(this) {
|
||||
relaunch = null
|
||||
relaunchSessionId = null
|
||||
relaunchStopRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic var hdr = false
|
||||
|
||||
@Volatile private var deckPadValue = false
|
||||
|
||||
@@ -65,6 +65,8 @@ private fun releaseDateValue(raw: String): Long =
|
||||
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
|
||||
} ?: 0L
|
||||
|
||||
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
|
||||
|
||||
fun <T> sortGames(
|
||||
items: List<T>,
|
||||
sort: String,
|
||||
|
||||
@@ -204,7 +204,8 @@ object BattleNetBackend : StoreBackend {
|
||||
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
|
||||
val art = BattleNet.art(context, game.title)
|
||||
val cover = art.cover?.let { Uri.fromFile(it).toString() }
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
|
||||
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
}
|
||||
|
||||
private fun shelves(context: Context): StoreShelves {
|
||||
|
||||
@@ -64,6 +64,8 @@ object GogBackend : StoreBackend {
|
||||
}, "gog-sync").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = GogStoreCatalog.search(query)
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
|
||||
|
||||
@@ -16,6 +16,7 @@ internal object GogContent {
|
||||
|
||||
fun token(context: Context) = GogAuth.validToken(context) ?: error("Sign in to GOG and retry")
|
||||
|
||||
@JvmStatic
|
||||
fun builds(id: String, token: String): JSONObject {
|
||||
val result = json("$SYSTEM/products/$id/os/windows/builds?generation=2", token)
|
||||
if (result.optJSONArray("items")?.length() != 0) return result
|
||||
@@ -24,6 +25,36 @@ internal object GogContent {
|
||||
|
||||
fun product(id: String, token: String): JSONObject = json("https://api.gog.com/products/$id?expand=downloads", token)
|
||||
|
||||
@JvmStatic
|
||||
fun ownedProductIds(token: String): Set<String>? = runCatching {
|
||||
val body = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token) ?: return@runCatching null
|
||||
JSONObject(body).optJSONArray("owned")?.strings()?.toSet() ?: emptySet()
|
||||
}.getOrNull()
|
||||
|
||||
@JvmStatic
|
||||
fun depotProducts(depot: JSONObject, baseProductId: String): List<String> =
|
||||
depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId").ifEmpty { baseProductId })
|
||||
|
||||
@JvmStatic
|
||||
fun clientCredentials(manifest: JSONObject): Pair<String, String>? {
|
||||
val clientId = manifest.optString("clientId")
|
||||
val clientSecret = manifest.optString("clientSecret")
|
||||
return clientId.takeIf { it.isNotBlank() }?.let { id -> clientSecret.takeIf { it.isNotBlank() }?.let { id to it } }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun tempExecutable(manifest: JSONObject): String? = manifest.optJSONArray("products")?.optJSONObject(0)
|
||||
?.optString("temp_executable")?.takeIf { it.isNotBlank() }
|
||||
|
||||
@JvmStatic
|
||||
fun installProducts(baseProductId: String, depots: JSONArray, installed: List<String>, token: String,
|
||||
ownership: (String) -> Set<String>? = { ownedProductIds(it) }): Set<String> {
|
||||
val listed = depots.objects().flatMap { depotProducts(it, baseProductId) }.distinct()
|
||||
val owned = if (listed.any { it != baseProductId } || installed.any { it != baseProductId }) ownership(token) else emptySet()
|
||||
check(installed.all { it == baseProductId || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
|
||||
return (listOf(baseProductId) + installed + listed.filter { it == baseProductId || owned?.contains(it) == true }).toSet()
|
||||
}
|
||||
|
||||
fun downloadFilename(url: String): String = java.net.URLDecoder.decode(URL(url).path.substringAfterLast('/').replace("+", "%2B"), "UTF-8")
|
||||
|
||||
fun resolveDownload(download: GogOfflineDownload, token: String): GogOfflineDownload = download.copy(parts = download.parts.map { part ->
|
||||
@@ -42,6 +73,7 @@ internal object GogContent {
|
||||
} finally { connection.disconnect() }
|
||||
})
|
||||
|
||||
@JvmStatic
|
||||
fun json(url: String, token: String? = null): JSONObject {
|
||||
require(URL(url).protocol == "https") { "GOG metadata requires HTTPS" }
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
@@ -61,21 +93,21 @@ internal object GogContent {
|
||||
}
|
||||
|
||||
fun snapshot(old: GogInstallRecord, build: GogBuild, token: String,
|
||||
metadata: (String, String?) -> JSONObject = ::json): GogInstallRecord {
|
||||
ownership: (String) -> Set<String>? = { ownedProductIds(it) },
|
||||
metadata: (String, String?) -> JSONObject = ::json,
|
||||
onManifest: (JSONObject) -> Unit = {}): GogInstallRecord {
|
||||
check(build.link.isNotBlank()) { "GOG build manifest is unavailable" }
|
||||
val manifest = metadata(build.link, token)
|
||||
onManifest(manifest)
|
||||
val files = JSONArray()
|
||||
val depots = if (build.generation == 1) manifest.optJSONObject("product")?.optJSONArray("depots") ?: manifest.optJSONArray("depot") else manifest.optJSONArray("depots")
|
||||
check(depots != null) { "GOG build has no depot list" }
|
||||
val resolvedLanguage = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), old.language)
|
||||
val selectedBitness = if (depots.objects().any { it.optJSONArray("osBitness")?.strings()?.contains("64") == true }) "64" else "32"
|
||||
val owned = if (old.products.any { it != old.id }) StoreNet.get("https://embed.gog.com/user/data/games", bearer = token)?.let { JSONObject(it).optJSONArray("owned")?.strings()?.toSet() } else emptySet()
|
||||
val products = old.products.filter { it == old.id || owned?.contains(it) == true }
|
||||
check(old.products.all { it == old.id || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
|
||||
val products = installProducts(old.id, depots, old.products, token, ownership)
|
||||
for (depot in depots.objects()) {
|
||||
if (depot.has("redist")) continue
|
||||
val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id))
|
||||
val product = ids.firstOrNull { it in products } ?: continue
|
||||
val product = depotProducts(depot, old.id).firstOrNull { it in products } ?: continue
|
||||
val languages = depot.optJSONArray("languages")?.strings().orEmpty()
|
||||
if (!languageSelected(languages, resolvedLanguage)) continue
|
||||
val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty()
|
||||
@@ -108,7 +140,7 @@ internal object GogContent {
|
||||
}
|
||||
check(files.length() > 0) { "GOG build contains no matching files" }
|
||||
return old.copy(source = "depot", generation = build.generation, build = build.id, version = build.version,
|
||||
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true)
|
||||
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products.toList(), verified = true)
|
||||
}
|
||||
|
||||
fun languageSelected(list: List<String>, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected)
|
||||
|
||||
@@ -125,211 +125,22 @@ public final class GogDownloadManager {
|
||||
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) {
|
||||
Gen2Outcome out = new Gen2Outcome();
|
||||
try {
|
||||
cb.onProgress("Fetching manifest…", 5);
|
||||
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, cb);
|
||||
if (gm == null) { out.error = "gen2 manifest unavailable"; return out; }
|
||||
JSONObject manifest = gm.manifest;
|
||||
JSONArray depots = manifest.optJSONArray("depots");
|
||||
if (depots == null) { out.error = "no depots in manifest"; return out; }
|
||||
JSONArray products = manifest.optJSONArray("products");
|
||||
String tempExe = null;
|
||||
if (products != null && products.length() > 0) {
|
||||
tempExe = products.getJSONObject(0).optString("temp_executable", null);
|
||||
if (tempExe != null && tempExe.isEmpty()) tempExe = null;
|
||||
}
|
||||
String baseProductId = manifest.optString("baseProductId", null);
|
||||
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
|
||||
baseProductId = products.getJSONObject(0).optString("productId", null);
|
||||
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
List<String> dependencies = new ArrayList<>();
|
||||
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
|
||||
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
|
||||
|
||||
cb.onProgress("Reading depot manifests…", 10);
|
||||
List<DepotFile> files = new ArrayList<>();
|
||||
List<String> depotJsons = new ArrayList<>();
|
||||
JSONArray owned = new JSONArray();
|
||||
List<JSONObject> ownedDepots = new ArrayList<>();
|
||||
for (int i = 0; i < depots.length(); i++) {
|
||||
JSONObject depot = depots.getJSONObject(i);
|
||||
String depotPid = depot.optString("productId", "");
|
||||
if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
|
||||
owned.put(depot);
|
||||
ownedDepots.add(depot);
|
||||
}
|
||||
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language);
|
||||
cb.onLog("gog: language " + resolved);
|
||||
for (int i = 0; i < ownedDepots.size(); i++) {
|
||||
if (cancelled.get()) return out;
|
||||
JSONObject depot = ownedDepots.get(i);
|
||||
if (!depotSelected(depot, resolved)) continue;
|
||||
String manifestHash = depot.optString("manifest");
|
||||
if (manifestHash == null || manifestHash.isEmpty()) continue;
|
||||
byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
|
||||
if (dmRaw == null) { cb.onLog("gog: depot " + i + " meta fetch failed"); continue; }
|
||||
String dmStr = decompressBytes(dmRaw);
|
||||
if (dmStr == null) { cb.onLog("gog: depot " + i + " decompress failed"); continue; }
|
||||
parseDepotManifest(dmStr, files);
|
||||
depotJsons.add(dmStr);
|
||||
}
|
||||
if (files.isEmpty()) { out.error = "no depot files collected"; return out; }
|
||||
|
||||
cb.onProgress("Fetching CDN link…", 15);
|
||||
String secureLinkUrl = "https://content-system.gog.com/products/" + baseProductId + "/secure_link?_version=2&generation=2&path=/";
|
||||
String cdnBase = parseCdnUrl(httpGet(secureLinkUrl, token));
|
||||
if (cdnBase == null) { out.error = "secure link refused"; return out; }
|
||||
|
||||
installPath.mkdirs();
|
||||
long plannedBytes = 0;
|
||||
for (DepotFile df : files) plannedBytes += df.totalSize;
|
||||
long remainingBytes = remainingInstallBytes(files, installPath);
|
||||
if (remainingBytes > 0) {
|
||||
long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath);
|
||||
if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(remainingBytes, usable)) {
|
||||
out.hardStop = true;
|
||||
out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Collections.sort(files, (a, b) -> Long.compare(b.totalSize, a.totalSize));
|
||||
|
||||
final int total = files.size();
|
||||
final long planned = plannedBytes;
|
||||
final AtomicInteger doneCount = new AtomicInteger(0);
|
||||
final AtomicLong totalBytes = new AtomicLong(0);
|
||||
final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
|
||||
final AtomicLong lastSpeedB = new AtomicLong(0);
|
||||
final AtomicLong speedBps = new AtomicLong(0);
|
||||
final AtomicBoolean anyFailed = new AtomicBoolean(false);
|
||||
final AtomicReference<String> cdnBaseRef = new AtomicReference<>(cdnBase);
|
||||
final AtomicInteger cdnRefreshCount = new AtomicInteger(0);
|
||||
final int MAX_CDN_REFRESH = 5;
|
||||
cb.onSizes(planned, planned);
|
||||
cb.onBytes(0, planned, 0);
|
||||
cb.onLog("gog: " + total + " files, " + formatBytes(planned) + ", base=" + baseProductId);
|
||||
|
||||
int threads = downloadThreads();
|
||||
if (GogNative.isAvailable()) {
|
||||
runNativeEngine(ctx, GogNative.KIND_GEN2_CHUNKS, "Verified…", depotJsons, installPath, cdnBaseRef, secureLinkUrl, token,
|
||||
cdnRefreshCount, MAX_CDN_REFRESH, threads, threads, true, "gog base=" + baseProductId,
|
||||
cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
|
||||
} else {
|
||||
cb.onLog("gog: engine=built-in (" + threads + " threads)");
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
|
||||
List<Future<Void>> futures = new ArrayList<>();
|
||||
for (DepotFile df : files) {
|
||||
futures.add(pool.submit((Callable<Void>) () -> {
|
||||
if (cancelled.get() || anyFailed.get()) return null;
|
||||
File outFile = new File(installPath, df.relativePath);
|
||||
outFile.getParentFile().mkdirs();
|
||||
if (fileVerified(outFile, df.totalSize, df.md5)) {
|
||||
int done = doneCount.incrementAndGet();
|
||||
long tb = totalBytes.addAndGet(df.totalSize);
|
||||
cb.onProgress("Verified…", 15 + (int) ((done / (float) total) * 80));
|
||||
cb.onBytes(tb, planned, speedBps.get());
|
||||
return null;
|
||||
}
|
||||
boolean ok = assembleDepotFile(df, outFile, cdnBaseRef, secureLinkUrl, token, cdnRefreshCount, MAX_CDN_REFRESH, cancelled, cb);
|
||||
if (cancelled.get()) return null;
|
||||
if (!ok) { cb.onLog("FAIL file=" + df.relativePath); anyFailed.set(true); return null; }
|
||||
int done = doneCount.incrementAndGet();
|
||||
long tb = totalBytes.addAndGet(df.totalSize);
|
||||
sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
|
||||
cb.onProgress("Downloading: " + baseName(df.relativePath) + speedSuffix(speedBps.get()), 15 + (int) ((done / (float) total) * 80));
|
||||
cb.onBytes(tb, planned, speedBps.get());
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
pool.shutdown();
|
||||
try {
|
||||
for (Future<Void> f : futures) f.get();
|
||||
} catch (Exception e) {
|
||||
pool.shutdownNow();
|
||||
out.error = "download pool error: " + e.getClass().getSimpleName();
|
||||
return out;
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) return out;
|
||||
if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; }
|
||||
|
||||
cb.onProgress("Finishing…", 96);
|
||||
try {
|
||||
GogMaintenance.captureDepot(game.gameId, installPath, gm.buildId, buildsJson, manifest, new JSONArray(depotJsons), resolved);
|
||||
} catch (Exception e) {
|
||||
cb.onLog("gog: maintenance inventory not recorded: " + e.getMessage());
|
||||
}
|
||||
writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8"));
|
||||
deleteCounted(new File(installPath, ".gog_chunks"), cb);
|
||||
String clientId = manifest.optString("clientId", null);
|
||||
if (clientId != null && !clientId.isEmpty()) GogPrefs.get(ctx).edit().putString("client_id_" + game.gameId, clientId).apply();
|
||||
String clientSecret = manifest.optString("clientSecret", null);
|
||||
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
|
||||
GameTask task = pickTask(installPath, game.gameId, tempExe, game.title);
|
||||
out.result = new Result(task == null ? "" : task.path, gm.buildId, planned);
|
||||
if (task != null) out.result.workDir = task.workDir;
|
||||
out.result.dependencies = dependencies;
|
||||
out.result.dependenciesRead = true;
|
||||
out.result.language = resolved;
|
||||
return out;
|
||||
out.result = GogMaintenance.installContent(ctx, game, installPath, new JSONObject(buildsJson), language, cb, cancelled);
|
||||
if (out.result == null && !cancelled.get()) out.error = "no usable public Windows build";
|
||||
} catch (Exception e) {
|
||||
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Gen2Manifest {
|
||||
final JSONObject manifest; final String buildId;
|
||||
Gen2Manifest(JSONObject manifest, String buildId) { this.manifest = manifest; this.buildId = buildId; }
|
||||
}
|
||||
|
||||
private static Gen2Manifest resolveGen2Manifest(String buildsJson, String token, Callback cb) {
|
||||
try {
|
||||
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
|
||||
if (items == null || items.length() == 0) { cb.onLog("gog: no gen2 builds"); return null; }
|
||||
String buildId = null, manifestUrl = null;
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject item = items.getJSONObject(i);
|
||||
if ("windows".equals(item.optString("os")) && (item.isNull("branch") || item.optString("branch").isEmpty())) {
|
||||
buildId = item.optString("build_id");
|
||||
manifestUrl = item.optString("link");
|
||||
if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (buildId == null || manifestUrl == null || manifestUrl.isEmpty()) { cb.onLog("gog: no windows build"); return null; }
|
||||
byte[] raw = fetchBytes(manifestUrl, token);
|
||||
if (raw == null) { cb.onLog("gog: manifest fetch failed"); return null; }
|
||||
String str = decompressBytes(raw);
|
||||
if (str == null) { cb.onLog("gog: manifest decompress failed"); return null; }
|
||||
return new Gen2Manifest(new JSONObject(str), buildId);
|
||||
} catch (Exception e) {
|
||||
cb.onLog("gog: manifest " + e.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean depotSelected(JSONObject depot, String resolved) {
|
||||
JSONArray languages = depot.optJSONArray("languages");
|
||||
if (languages == null || languages.length() == 0) return true;
|
||||
String[] listed = new String[languages.length()];
|
||||
for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, "");
|
||||
return StoreLanguage.depotMatchesResolved(listed, resolved);
|
||||
return out;
|
||||
}
|
||||
|
||||
public static List<String> listLanguages(Context ctx, String gameId) {
|
||||
try {
|
||||
String token = GogAuth.validToken(ctx);
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
String buildsJson = httpGet(buildsUrl, null);
|
||||
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
|
||||
if (buildsJson == null) return Collections.emptyList();
|
||||
Callback silent = new Callback() {
|
||||
@Override public void onProgress(String message, int pct) {}
|
||||
};
|
||||
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent);
|
||||
if (gm == null) return Collections.emptyList();
|
||||
return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots"));
|
||||
JSONObject builds = GogContent.builds(gameId, token);
|
||||
GogBuild build = GogBuild.Companion.select(builds, "", "");
|
||||
if (build == null || build.getGeneration() != 2) return Collections.emptyList();
|
||||
return StoreLanguage.codesFromDepots(GogContent.json(build.getLink(), token).optJSONArray("depots"));
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -398,7 +209,7 @@ public final class GogDownloadManager {
|
||||
int threads = downloadThreads();
|
||||
if (GogNative.isAvailable()) {
|
||||
runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath,
|
||||
new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId,
|
||||
threads, threads, false, "gog gen1=" + game.gameId,
|
||||
cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
|
||||
} else {
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
|
||||
@@ -659,8 +470,7 @@ public final class GogDownloadManager {
|
||||
|
||||
|
||||
private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List<String> depotJsons, File installPath,
|
||||
AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
|
||||
AtomicInteger cdnRefreshCount, int maxCdnRefresh, int maxWorkers, int processWorkers,
|
||||
int maxWorkers, int processWorkers,
|
||||
boolean sortLargestFirst, String label, AtomicBoolean cancelled, AtomicBoolean anyFailed,
|
||||
AtomicInteger doneCount, int total, long planned, AtomicLong totalBytes,
|
||||
AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps, Callback cb) {
|
||||
@@ -673,10 +483,8 @@ public final class GogDownloadManager {
|
||||
int run = 0;
|
||||
while (!cancelled.get()) {
|
||||
run++;
|
||||
final String base = cdnBaseRef.get();
|
||||
final CountDownLatch latch = new CountDownLatch(1);
|
||||
final AtomicBoolean okRef = new AtomicBoolean(false);
|
||||
final AtomicBoolean linkExpiryRef = new AtomicBoolean(false);
|
||||
final AtomicReference<String> errRef = new AtomicReference<>("");
|
||||
final long runStart = totalBytes.get();
|
||||
final AtomicLong verifiedInRun = new AtomicLong(0);
|
||||
@@ -702,12 +510,10 @@ public final class GogDownloadManager {
|
||||
@Override public void onLog(String line) { cb.onLog(line); }
|
||||
@Override public void onMaintenance(long networkBytes, long reusedBytes, long appliedBytes) {}
|
||||
@Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) {
|
||||
okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown();
|
||||
okRef.set(success); errRef.set(error == null ? "" : error); latch.countDown();
|
||||
}
|
||||
};
|
||||
String[] bases = (base == null || base.isEmpty()) ? new String[0] : base.split("\n");
|
||||
if (run == 1) cb.onLog("gog: " + bases.length + " CDN host(s) offered");
|
||||
long handle = GogNative.start(kind, manifests, bases, installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
|
||||
long handle = GogNative.start(kind, manifests, new String[0], installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
|
||||
workers, process, sortLargestFirst, label + " run=" + run, listener);
|
||||
if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; }
|
||||
boolean interrupted = false;
|
||||
@@ -721,85 +527,13 @@ public final class GogDownloadManager {
|
||||
}
|
||||
if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; }
|
||||
if (cancelled.get() || okRef.get()) return;
|
||||
String err = errRef.get();
|
||||
if (linkExpiryRef.get() && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) {
|
||||
cb.onLog("gog: secure link expired (" + err + "); refreshed, running again for the remaining files");
|
||||
continue;
|
||||
}
|
||||
cb.onLog("gog: engine failed: " + err);
|
||||
cb.onLog("gog: engine failed: " + errRef.get());
|
||||
anyFailed.set(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void parseDepotManifest(String json, List<DepotFile> out) {
|
||||
try {
|
||||
JSONObject depotObj = new JSONObject(json).optJSONObject("depot");
|
||||
if (depotObj == null) return;
|
||||
JSONArray depot = depotObj.optJSONArray("items");
|
||||
if (depot == null) return;
|
||||
for (int i = 0; i < depot.length(); i++) {
|
||||
JSONObject entry = depot.getJSONObject(i);
|
||||
String path = entry.optString("path", "").replace("\\", "/");
|
||||
if (path.startsWith("/")) path = path.substring(1);
|
||||
JSONArray chunks = entry.optJSONArray("chunks");
|
||||
if (path.isEmpty() || chunks == null || chunks.length() == 0) continue;
|
||||
DepotFile df = new DepotFile(path);
|
||||
String fileMd5 = entry.optString("md5", null);
|
||||
if (fileMd5 != null && !fileMd5.isEmpty()) df.md5 = fileMd5;
|
||||
long fileTotal = 0;
|
||||
for (int c = 0; c < chunks.length(); c++) {
|
||||
JSONObject chunk = chunks.getJSONObject(c);
|
||||
String compressedMd5 = chunk.optString("compressedMd5"), decMd5 = chunk.optString("md5");
|
||||
String hash = (compressedMd5 != null && !compressedMd5.isEmpty()) ? compressedMd5 : decMd5;
|
||||
if (hash == null || hash.isEmpty()) continue;
|
||||
long decSize = chunk.optLong("size", 0);
|
||||
df.chunks.add(new DepotFile.ChunkRef(hash, compressedMd5, decMd5, chunk.optLong("compressedSize", 0), decSize));
|
||||
fileTotal += decSize;
|
||||
}
|
||||
df.totalSize = fileTotal;
|
||||
if (!df.chunks.isEmpty()) out.add(df);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "depot manifest parse: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
static String buildCdnPath(String hash) { return hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash; }
|
||||
|
||||
private static boolean assembleDepotFile(DepotFile df, File outFile, AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
|
||||
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
|
||||
File tmpFile = new File(outFile.getAbsolutePath() + ".bhtmp");
|
||||
File parent = outFile.getParentFile();
|
||||
if (parent != null) parent.mkdirs();
|
||||
tmpFile.delete();
|
||||
boolean ok = true;
|
||||
try (FileOutputStream fos = new FileOutputStream(tmpFile)) {
|
||||
for (DepotFile.ChunkRef chunk : df.chunks) {
|
||||
if (cancelled.get()) { ok = false; break; }
|
||||
byte[] inflated = fetchChunkVerified(cdnBaseRef, buildCdnPath(chunk.hash), chunk, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cancelled, cb);
|
||||
if (inflated == null) { ok = false; break; }
|
||||
fos.write(inflated);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ok = false;
|
||||
}
|
||||
if (!ok || cancelled.get()) { tmpFile.delete(); return false; }
|
||||
if (df.totalSize > 0 && tmpFile.length() != df.totalSize) { cb.onLog("size mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
|
||||
if (df.md5 != null && !df.md5.isEmpty()) {
|
||||
String actual = md5HexFile(tmpFile);
|
||||
if (actual == null || !actual.equalsIgnoreCase(df.md5)) { cb.onLog("md5 mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
|
||||
}
|
||||
if (outFile.exists()) outFile.delete();
|
||||
return tmpFile.renameTo(outFile);
|
||||
}
|
||||
|
||||
private static String parseCdnUrl(String json) {
|
||||
java.util.List<String> all = parseCdnUrls(json);
|
||||
return all.isEmpty() ? null : String.join("\n", all);
|
||||
}
|
||||
|
||||
static java.util.List<String> parseCdnUrls(String json) {
|
||||
java.util.List<String> out = new ArrayList<>();
|
||||
if (json == null) return out;
|
||||
@@ -824,18 +558,6 @@ public final class GogDownloadManager {
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final AtomicInteger CDN_TURN = new AtomicInteger();
|
||||
|
||||
static String pickCdn(String set) {
|
||||
String[] all = set.split("\n");
|
||||
return all[Math.floorMod(CDN_TURN.getAndIncrement(), all.length)];
|
||||
}
|
||||
|
||||
static String firstCdn(String set) {
|
||||
int nl = set.indexOf('\n');
|
||||
return nl < 0 ? set : set.substring(0, nl);
|
||||
}
|
||||
|
||||
private static boolean downloadRange(String url, long offset, long size, File out) {
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
@@ -951,15 +673,6 @@ public final class GogDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static String buildChunkUrl(String base, String chunkPath) {
|
||||
int q = base.indexOf('?');
|
||||
return q >= 0 ? base.substring(0, q) + "/" + chunkPath + base.substring(q) : base + "/" + chunkPath;
|
||||
}
|
||||
|
||||
private static String md5Hex(byte[] data) {
|
||||
try { return toHex(java.security.MessageDigest.getInstance("MD5").digest(data)); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private static String md5HexFile(File f) {
|
||||
try (FileInputStream fis = new FileInputStream(f)) {
|
||||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
|
||||
@@ -988,58 +701,6 @@ public final class GogDownloadManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void sleepBackoff(int attempt) {
|
||||
try { Thread.sleep(Math.min(1000L << Math.max(0, attempt - 1), 8000L)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
private static boolean tryRefreshCdn(AtomicReference<String> cdnBaseRef, String staleBase, String secureLinkUrl, String token,
|
||||
AtomicInteger refreshCount, int cap, Callback cb) {
|
||||
if (secureLinkUrl == null) return false;
|
||||
synchronized (cdnBaseRef) {
|
||||
String current = cdnBaseRef.get();
|
||||
if (staleBase == null || !staleBase.equals(current)) return true;
|
||||
if (refreshCount.get() >= cap) { cb.onLog("gog: CDN refresh cap reached (" + cap + ")"); return false; }
|
||||
String neu = parseCdnUrl(httpGet(secureLinkUrl, token));
|
||||
if (neu == null || neu.isEmpty()) { cb.onLog("gog: CDN refresh failed"); return false; }
|
||||
cdnBaseRef.set(neu);
|
||||
cb.onLog("gog: secure link refreshed #" + refreshCount.incrementAndGet());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] fetchChunkVerified(AtomicReference<String> cdnBaseRef, String chunkPath, DepotFile.ChunkRef chunk, String secureLinkUrl, String token,
|
||||
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
|
||||
int hardFail = 0, guard = 0;
|
||||
while (guard++ < 8) {
|
||||
if (cancelled.get()) return null;
|
||||
String base = cdnBaseRef.get();
|
||||
HttpResult res = fetchBytesEx(buildChunkUrl(pickCdn(base), chunkPath), null);
|
||||
if (res.body == null) {
|
||||
int code = res.status;
|
||||
if ((code == 401 || code == 403 || code == 404 || code == 500) && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) continue;
|
||||
cb.onLog("chunk http fail code=" + code);
|
||||
if (++hardFail >= 3) return null;
|
||||
sleepBackoff(hardFail);
|
||||
continue;
|
||||
}
|
||||
byte[] raw = res.body;
|
||||
if (chunk.compressedSize > 0 && raw.length != chunk.compressedSize) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
if (chunk.compressedMd5 != null && !chunk.compressedMd5.isEmpty()) {
|
||||
String actual = md5Hex(raw);
|
||||
if (actual == null || !actual.equalsIgnoreCase(chunk.compressedMd5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
}
|
||||
byte[] inflated = inflateZlib(raw);
|
||||
if (inflated == null) inflated = raw;
|
||||
if (chunk.size > 0 && inflated.length != chunk.size) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
if (chunk.md5 != null && !chunk.md5.isEmpty()) {
|
||||
String actual = md5Hex(inflated);
|
||||
if (actual == null || !actual.equalsIgnoreCase(chunk.md5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
}
|
||||
return inflated;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void sampleSpeed(long totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps) {
|
||||
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
|
||||
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
|
||||
@@ -1289,30 +950,6 @@ public final class GogDownloadManager {
|
||||
for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out);
|
||||
}
|
||||
|
||||
static long remainingInstallBytes(List<DepotFile> files, File installPath) {
|
||||
long remaining = 0;
|
||||
for (DepotFile file : files) {
|
||||
File output = new File(installPath, file.relativePath);
|
||||
if (!fileVerified(output, file.totalSize, file.md5)) remaining += file.totalSize;
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
static final class DepotFile {
|
||||
final String relativePath;
|
||||
final List<ChunkRef> chunks = new ArrayList<>();
|
||||
String md5;
|
||||
long totalSize;
|
||||
DepotFile(String relativePath) { this.relativePath = relativePath; }
|
||||
static final class ChunkRef {
|
||||
final String hash, compressedMd5, md5;
|
||||
final long compressedSize, size;
|
||||
ChunkRef(String hash, String compressedMd5, String md5, long compressedSize, long size) {
|
||||
this.hash = hash; this.compressedMd5 = compressedMd5; this.md5 = md5; this.compressedSize = compressedSize; this.size = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Gen1File {
|
||||
final String path, url; final long offset, size;
|
||||
Gen1File(String path, String url, long offset, long size) { this.path = path; this.url = url; this.offset = offset; this.size = size; }
|
||||
@@ -1382,7 +1019,11 @@ public final class GogDownloadManager {
|
||||
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode());
|
||||
for (int i = 0; i < depots.length(); i++) {
|
||||
JSONObject depot = depots.getJSONObject(i);
|
||||
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
|
||||
JSONArray languages = depot.optJSONArray("languages");
|
||||
String[] listed = new String[languages == null ? 0 : languages.length()];
|
||||
for (int j = 0; j < listed.length; j++) listed[j] = languages.optString(j, "");
|
||||
if (listed.length == 0 || StoreLanguage.depotMatchesResolved(listed, resolved))
|
||||
total += depot.optLong("size", 0);
|
||||
}
|
||||
return total > 0 ? total : -1;
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -58,16 +58,17 @@ object GogLibrary {
|
||||
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
|
||||
onStatus(app.getString(R.string.stores_sync_game_list))
|
||||
val listing = ownedListing(token)
|
||||
val account = ownedIds(token)
|
||||
val listed: List<GogGame> = listing?.games
|
||||
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
|
||||
?: account?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
|
||||
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
|
||||
val named = listing != null
|
||||
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
|
||||
val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }
|
||||
val cachedList = cached(app)
|
||||
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
|
||||
val ownedSet = ownedSet(listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }, account, cachedIds)
|
||||
StoresState.logLine(if (named) "GOG: listing ${listed.size} games, ${ownedSet.size} owned ids" else "GOG: listing unreadable, ${listed.size} owned ids")
|
||||
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
|
||||
val heavy = force || cachedList.isEmpty() || stale
|
||||
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
|
||||
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
|
||||
skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
|
||||
val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped }
|
||||
@@ -133,6 +134,9 @@ object GogLibrary {
|
||||
return all.ifEmpty { null }?.let { Listing(it, owned) }
|
||||
}
|
||||
|
||||
fun ownedSet(listed: Set<String>, account: List<String>?, cached: Set<String> = emptySet()): Set<String> =
|
||||
if (account == null) listed + cached else listed + account
|
||||
|
||||
private fun ownedIds(token: String): List<String>? {
|
||||
val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null
|
||||
val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null
|
||||
|
||||
@@ -377,16 +377,24 @@ internal object GogMaintenance {
|
||||
val build = GogBuild.select(builds) ?: return null
|
||||
folder.mkdirs()
|
||||
val old = GogInstallRecord(game.gameId, "depot", language = language)
|
||||
val target = GogContent.snapshot(old, build, GogContent.token(context))
|
||||
var credentials: Pair<String, String>? = null
|
||||
val target = GogContent.snapshot(old, build, GogContent.token(context), onManifest = { manifest ->
|
||||
credentials = GogContent.clientCredentials(manifest)
|
||||
})
|
||||
GogFiles.validate(target.files, folder)
|
||||
val bytes = target.files.objects().sumOf { it.optJSONArray("chunks")?.objects()?.sumOf { c -> c.optLong("compressedSize") }?.takeIf { n -> n > 0 } ?: it.optLong("size") }
|
||||
val disk = target.files.objects().sumOf { it.optLong("size") }
|
||||
check(!StoreInstallRoot.insufficientSpace(disk, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
|
||||
val remaining = GogEstimate.calculate(folder, old, target, JSONArray(), reuseChunks = GogNative.isAvailable()).required
|
||||
check(!StoreInstallRoot.insufficientSpace(remaining, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
|
||||
callback.onSizes(bytes, disk)
|
||||
transfer(context, Proposal(folder, old, target, GogOperation.UPDATE, bytes, disk), null, cancelled, callback)
|
||||
if (cancelled.get()) return null
|
||||
target.write(folder)
|
||||
val task = GogDownloadManager.pickTask(folder, game.gameId, null, game.title)
|
||||
credentials?.let { (clientId, clientSecret) ->
|
||||
GogPrefs.get(context).edit().putString("client_id_${game.gameId}", clientId)
|
||||
.putString("client_secret_${game.gameId}", clientSecret).apply()
|
||||
}
|
||||
val task = GogDownloadManager.pickTask(folder, game.gameId, GogContent.tempExecutable(target.manifest), game.title)
|
||||
val result = GogDownloadManager.Result(task?.path ?: "", build.id, disk)
|
||||
result.workDir = task?.workDir
|
||||
result.language = target.language
|
||||
@@ -395,20 +403,6 @@ internal object GogMaintenance {
|
||||
return result
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun captureDepot(id: String, folder: File, build: String, buildsJson: String, manifest: JSONObject, depots: JSONArray, language: String) {
|
||||
val selected = GogBuild.select(JSONObject(buildsJson), pinned = build)
|
||||
val files = JSONArray()
|
||||
for (depot in depots.strings()) for (item in JSONObject(depot).getJSONObject("depot").getJSONArray("items").objects()) {
|
||||
val chunks = item.optJSONArray("chunks") ?: continue
|
||||
files.put(JSONObject().put("path", item.getString("path").replace('\\', '/')).put("size", chunks.objects().sumOf { it.optLong("size") })
|
||||
.put("md5", item.optString("md5")).put("chunks", chunks).put("product", id))
|
||||
}
|
||||
GogFiles.validate(files, folder)
|
||||
GogInstallRecord(id, "depot", 2, build, selected?.version.orEmpty(), selected?.branch.orEmpty(), language,
|
||||
manifest = GogContent.sanitize(manifest), files = files).write(folder)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun captureOffline(id: String, folder: File, downloads: JSONObject) {
|
||||
val selected = GogOfflineDownload.parse(downloads, "installers").firstOrNull() ?: return
|
||||
|
||||
@@ -6,8 +6,6 @@ import com.droiddeck.launcher.stores.StoresNative
|
||||
object GogNative {
|
||||
private const val TAG = "GogNative"
|
||||
|
||||
const val KIND_GEN2_CHUNKS = 0
|
||||
|
||||
const val KIND_GEN1_RANGES = 1
|
||||
|
||||
interface Listener {
|
||||
|
||||
@@ -151,7 +151,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (
|
||||
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it, UserApps.githubAsset(repo)), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.FLATPAK -> bundlePath?.takeIf { it.endsWith(".flatpak", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.FlatpakBundle(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
?: flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.BottomNotifications
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal fun notificationAccessIntent(context: android.content.Context): Intent =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
|
||||
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
|
||||
android.content.ComponentName(context, com.droiddeck.launcher.session.BottomNotificationListener::class.java).flattenToString(),
|
||||
)
|
||||
} else Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
|
||||
@Composable
|
||||
fun BottomNotificationsPage(openSettings: ((Intent, () -> Boolean) -> Unit)?) {
|
||||
val context = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var granted by remember { mutableStateOf(BottomNotifications.granted(context)) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
granted = BottomNotifications.granted(context)
|
||||
delay(2_000L)
|
||||
}
|
||||
}
|
||||
if (!granted) {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)) {
|
||||
Text(stringResource(R.string.bottom_notifications_why), fontSize = 15.sp, color = colors.onBackground, textAlign = TextAlign.Center)
|
||||
BottomPillButton(stringResource(R.string.bottom_notifications_allow), "bottom-notifications-allow") {
|
||||
val intent = notificationAccessIntent(context)
|
||||
if (openSettings != null) openSettings(intent) { BottomNotifications.granted(context) }
|
||||
else runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val shown = BottomNotifications.items
|
||||
if (shown.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.bottom_notifications_none), fontSize = 15.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
return
|
||||
}
|
||||
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.bottom_notifications_count, shown.size), fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f))
|
||||
BottomPillButton(stringResource(R.string.bottom_notifications_clear), "bottom-notifications-clear") { BottomNotifications.clear() }
|
||||
}
|
||||
LazyColumn(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(shown.toList(), key = { it.key }) { n ->
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(n.app, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Text(timeFormat.format(java.util.Date(n.postedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (n.title.isNotBlank()) Text(n.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (n.text.isNotBlank()) Text(n.text, fontSize = 14.sp, color = colors.onBackground, maxLines = 4, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Box(
|
||||
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
|
||||
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.Speed
|
||||
import androidx.compose.material.icons.outlined.Notifications
|
||||
import androidx.compose.material.icons.outlined.PhotoLibrary
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
class BottomPageActions(
|
||||
val takeScreenshot: ((onDone: (Boolean) -> Unit) -> Unit)? = null,
|
||||
val fps: () -> Float? = { null },
|
||||
val openSettings: ((intent: android.content.Intent, done: () -> Boolean) -> Unit)? = null,
|
||||
val runningGame: () -> String? = { null },
|
||||
)
|
||||
|
||||
@@ -20,7 +34,18 @@ internal const val BOTTOM_PAGE_TELEMETRY = "telemetry"
|
||||
fun sessionBottomPages(actions: BottomPageActions): List<BottomPage> = listOf(
|
||||
BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) },
|
||||
BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
|
||||
BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) },
|
||||
BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) },
|
||||
BottomPage("chat", R.string.bottom_page_chat, Icons.Outlined.Forum) { BottomChatPage() },
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Box(
|
||||
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
|
||||
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,8 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
|
||||
val now = System.nanoTime()
|
||||
val live = frames.live
|
||||
val stats = frames.stats
|
||||
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) }
|
||||
val graph = FloatArray(FrameStats.GRAPH_FRAMES)
|
||||
val graphEnd = if (live) stats.graph(graph) else 0L
|
||||
val hz = (frames.refreshMilliHz + 499) / 1000
|
||||
val data = HudData(
|
||||
fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY,
|
||||
@@ -317,6 +318,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
|
||||
title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock,
|
||||
fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(),
|
||||
historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()),
|
||||
graphEnd = graphEnd,
|
||||
coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit,
|
||||
tempColor = { sensor, c ->
|
||||
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN)
|
||||
|
||||
@@ -16,7 +16,7 @@ import com.droiddeck.launcher.input.*
|
||||
internal fun InputActionEditor(
|
||||
control: InputControl, current: InputAction, title: String, onBack: () -> Unit, onSave: (InputAction) -> Unit,
|
||||
element: InputSurfaceElement? = null, nativeTargets: Boolean = true, detail: String? = null,
|
||||
properties: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
properties: (@Composable ColumnScope.(InputAction) -> Unit)? = null,
|
||||
click: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -46,7 +46,7 @@ internal fun InputActionEditor(
|
||||
SettingsPage(host, title, onBack, eyebrow = stringResource(R.string.input_properties), lede = detail,
|
||||
action = { PrimaryButton(stringResource(R.string.gesture_save_changes), enabled = valid(draft),
|
||||
modifier = Modifier.testTag("input-action-save")) { onSave(draft) } }) {
|
||||
properties?.invoke(this)
|
||||
properties?.invoke(this, draft)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SheetChips(listOf(stringResource(R.string.input_route_controller), stringResource(R.string.input_route_local), stringResource(R.string.input_disabled)), route) {
|
||||
when (it) {
|
||||
|
||||
@@ -58,12 +58,17 @@ internal fun InputDisplayContent(host: ComponentActivity, display: Display, onDi
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowShown(display.displayId)
|
||||
showPresentation()
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
|
||||
if (event == Lifecycle.Event.ON_START) showPresentation()
|
||||
}
|
||||
host.lifecycle.addObserver(observer)
|
||||
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
|
||||
onDispose {
|
||||
host.lifecycle.removeObserver(observer)
|
||||
presentation.dismiss()
|
||||
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowClosed(display.displayId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal fun InputElementProperties(element: InputSurfaceElement, onChange: (InputSurfaceElement) -> Unit) {
|
||||
internal fun InputElementProperties(element: InputSurfaceElement, action: InputAction? = null, onChange: (InputSurfaceElement) -> Unit) {
|
||||
val current by rememberUpdatedState(element)
|
||||
val change by rememberUpdatedState(onChange)
|
||||
OutlinedTextField(element.name, { onChange(element.copy(name = it.take(80))) }, label = { Text(stringResource(R.string.input_name)) },
|
||||
@@ -72,7 +72,9 @@ internal fun InputElementProperties(element: InputSurfaceElement, onChange: (Inp
|
||||
}
|
||||
val host = rememberMenuHost()
|
||||
if ((element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) && element.icon == 0 && element.image.isEmpty() ||
|
||||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:"))
|
||||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:") ||
|
||||
(element.kind == InputElementKind.STICK || element.kind == InputElementKind.DPAD) && (action as? InputAction.Directions)
|
||||
?.let { listOf(it.up, it.right, it.down, it.left).plus(if (element.kind == InputElementKind.STICK) it.diagonals.orEmpty() else emptyList()).any { code -> code != 0 } } == true)
|
||||
ToggleRow(host, "element-label", stringResource(R.string.input_show_label), null, element.showLabel) { onChange(element.copy(showLabel = it)) }
|
||||
SliderRow(stringResource(R.string.ctrl_opacity), null, (element.opacity * 100).roundToInt().coerceIn(20, 100), 20..100, step = 5,
|
||||
format = { "$it%" }, modifier = Modifier.testTag("setting-element-opacity")) { onChange(element.copy(opacity = it / 100f)) }
|
||||
|
||||
@@ -169,7 +169,7 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe
|
||||
?: b.controls[editing.control] ?: InputAction.Native()
|
||||
InputActionEditor(editing.control, current, if (editing.click) stringResource(R.string.input_click_name, editing.element!!.name)
|
||||
else editing.element?.name ?: editing.key?.label ?: editing.control.label,
|
||||
properties = { if (!editing.click) elementProperties?.let { e -> InputElementProperties(e) { elementProperties = it } } },
|
||||
properties = { action -> if (!editing.click) elementProperties?.let { e -> InputElementProperties(e, action) { elementProperties = it } } },
|
||||
onBack = { edit = null }, element = editing.element, nativeTargets = editing.key == null, click = editing.click,
|
||||
detail = if (library.usersOfBindings(b.id).size > 1) stringResource(R.string.input_shared_detail, b.name, library.usersOfBindings(b.id).size)
|
||||
else "${profile.name} · ${roleLabel(role)}", onSave = { action ->
|
||||
|
||||
@@ -69,7 +69,7 @@ private class GamepadScaler(private val context: android.content.Context, privat
|
||||
}
|
||||
|
||||
private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings, val defaults: List<InputSurfaceElement>? = null,
|
||||
private val gamepad: GamepadScaler? = null) {
|
||||
private val gamepad: GamepadScaler? = null, snap: Boolean = true) {
|
||||
var elements by mutableStateOf(layout.elements)
|
||||
var actions by mutableStateOf(preset.elements)
|
||||
var clicks by mutableStateOf(preset.clicks)
|
||||
@@ -77,7 +77,7 @@ private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputB
|
||||
var toolsExpanded by mutableStateOf(false)
|
||||
var toolsX by mutableFloatStateOf(.02f)
|
||||
var toolsY by mutableFloatStateOf(.02f)
|
||||
var snap by mutableStateOf(true)
|
||||
var snap by mutableStateOf(snap)
|
||||
var adding by mutableStateOf(false)
|
||||
var properties by mutableStateOf(false)
|
||||
var clickProperties by mutableStateOf(false)
|
||||
@@ -132,7 +132,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
val (w, h) = OnScreenLayout.generatedSize(context, savedLayout, metrics.widthPixels, metrics.heightPixels) ?: (metrics.widthPixels to metrics.heightPixels)
|
||||
GamepadScaler(display, w, h)
|
||||
}
|
||||
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad)
|
||||
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad, ControllerPrefs.snap(context))
|
||||
}
|
||||
val manager = remember { context.getSystemService(DisplayManager::class.java) }
|
||||
var revision by remember { mutableIntStateOf(0) }
|
||||
@@ -162,7 +162,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
val listener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(id: Int) { revision++ }
|
||||
override fun onDisplayRemoved(id: Int) { revision++ }
|
||||
override fun onDisplayChanged(id: Int) { revision++ }
|
||||
override fun onDisplayChanged(id: Int) = Unit
|
||||
}
|
||||
manager.registerDisplayListener(listener, Handler(Looper.getMainLooper()))
|
||||
onDispose { manager.unregisterDisplayListener(listener) }
|
||||
@@ -171,13 +171,18 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
val presentation = SurfaceEditorPresentation(host, target, transparent = host is SessionActivity && role == InputRole.PRIMARY) {
|
||||
SurfaceCanvas(draft, transparent = host is SessionActivity && role == InputRole.PRIMARY)
|
||||
}
|
||||
(host as? SessionActivity)?.inputWindowShown(target.displayId)
|
||||
runCatching { presentation.show() }.onFailure { failure = it.message }
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
|
||||
if (event == Lifecycle.Event.ON_START) runCatching { presentation.show() }
|
||||
}
|
||||
host.lifecycle.addObserver(observer)
|
||||
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
|
||||
onDispose {
|
||||
host.lifecycle.removeObserver(observer)
|
||||
presentation.dismiss()
|
||||
(host as? SessionActivity)?.inputWindowClosed(target.displayId)
|
||||
}
|
||||
}
|
||||
// Compose 1.6 custom dialog sizing subtracts system bars even when they are hidden.
|
||||
Dialog(onDismissRequest = ::leave, properties = DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = false)) {
|
||||
@@ -198,8 +203,8 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
if (draft.clickProperties) draft.clickAction(element) else draft.action(element),
|
||||
if (draft.clickProperties) stringResource(R.string.input_click_name, element.name) else element.name,
|
||||
onBack = { draft.properties = false }, element = element, click = draft.clickProperties,
|
||||
properties = {
|
||||
if (!draft.clickProperties) InputElementProperties(properties) { properties = it }
|
||||
properties = { action ->
|
||||
if (!draft.clickProperties) InputElementProperties(properties, action) { properties = it }
|
||||
if (layoutUsers > 1) Text(stringResource(R.string.input_editor_shared, layoutUsers),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp))
|
||||
@@ -309,13 +314,14 @@ private fun FloatingSurfaceTools(draft: SurfaceDraft, onSave: () -> Unit, onLeav
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SurfaceToolbar(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
FlowRow(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onLeave)
|
||||
SecondaryButton(stringResource(R.string.input_editor_add), enabled = draft.elements.size < 64, compact = true) { draft.adding = true }
|
||||
SecondaryButton(stringResource(R.string.input_editor_undo), enabled = draft.history.isNotEmpty(), compact = true) { draft.undo() }
|
||||
if (draft.defaults != null) SecondaryButton(stringResource(R.string.input_reset_positions), enabled = draft.positioned != null, compact = true) { draft.resetPositions() }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(draft.snap, { draft.snap = it })
|
||||
Checkbox(draft.snap, { draft.snap = it; ControllerPrefs.setSnap(context, it) })
|
||||
Text(stringResource(R.string.ctrl_editor_snap))
|
||||
}
|
||||
PrimaryButton(stringResource(R.string.input_editor_done), compact = true, onClick = onSave)
|
||||
|
||||
@@ -32,10 +32,12 @@ import com.droiddeck.launcher.input.InputSurfaceStyle
|
||||
import com.droiddeck.launcher.input.pointerSubset
|
||||
import com.droiddeck.launcher.input.InputSurfaceSource
|
||||
import com.droiddeck.launcher.input.InputElementKind
|
||||
import com.droiddeck.launcher.input.InputAction
|
||||
import com.droiddeck.launcher.input.InputButtonIcons
|
||||
import com.droiddeck.launcher.input.InputButtonShape
|
||||
import com.droiddeck.launcher.input.InputButtonImages
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.pcKeyLabel
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
@@ -195,7 +197,7 @@ class InputSurfaceView(
|
||||
canvas.addView(when (element.kind) {
|
||||
InputElementKind.STICK -> StickView(context, element, source, element.floating, element.hideWhenIdle && pad != null)
|
||||
InputElementKind.TRACKPAD -> TrackpadView(context, element.control == InputControl.RIGHT_PAD, source = source)
|
||||
InputElementKind.DPAD -> DirectionPad(context, source)
|
||||
InputElementKind.DPAD -> DirectionPad(context, element, source)
|
||||
else -> HoldButton(context, element, source)
|
||||
}.apply { tag = element; contentDescription = element.name; styleElement(this); source?.onStateChanged = { postInvalidate() } })
|
||||
}
|
||||
@@ -541,6 +543,7 @@ class InputSurfaceView(
|
||||
if (touching || clicking) shade(color, 160, 1f) else shade(color, 120, 0.35f), if (touching) light(color, 230, 0.7f) else light(color, 150, 0.25f))
|
||||
if (!element.showLabel) return
|
||||
labelPaint.color = textColor(touching)
|
||||
if (drawDirectionLabels(canvas, source, false, cx, cy, radius * 0.66f, radius * 0.34f, labelPaint)) return
|
||||
labelPaint.textSize = knobRadius * 0.8f
|
||||
val label = if (element.name == element.control.label) glyph(element.control) else element.name
|
||||
canvas.drawText(label, cx + knobX, cy + knobY + labelPaint.textSize * 0.35f, labelPaint)
|
||||
@@ -552,6 +555,9 @@ class InputSurfaceView(
|
||||
canvas.drawCircle(cx, cy, reach(), ring)
|
||||
knob.color = if (touching) ACCENT else KNOB
|
||||
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
|
||||
if (!element.showLabel) return
|
||||
labelPaint.color = if (touching) ACCENT else TEXT
|
||||
drawDirectionLabels(canvas, source, false, cx, cy, reach() * 1.32f, reach() * 0.68f, labelPaint)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@@ -613,10 +619,11 @@ class InputSurfaceView(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DirectionPad(context: Context, private val source: InputSurfaceSource? = null) : View(context) {
|
||||
private inner class DirectionPad(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource? = null) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD); color = TEXT }
|
||||
private val rect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
@@ -636,6 +643,7 @@ class InputSurfaceView(
|
||||
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
|
||||
arm.color = KNOB
|
||||
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
|
||||
if (element.showLabel) drawDirectionLabels(canvas, source, true, cx, cy, (half + thick) / 2f, thick * 1.7f, label)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@@ -717,6 +725,29 @@ class InputSurfaceView(
|
||||
else -> control.id.uppercase()
|
||||
}
|
||||
|
||||
private val CARDINAL = listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)
|
||||
private val DIAGONAL = listOf(.7071f to -.7071f, .7071f to .7071f, -.7071f to .7071f, -.7071f to -.7071f)
|
||||
|
||||
internal fun directionLabels(context: Context, action: InputAction.Directions?, cardinalOnly: Boolean): List<Pair<Pair<Float, Float>, String>> {
|
||||
if (action == null) return emptyList()
|
||||
val codes = listOf(action.up, action.right, action.down, action.left).zip(CARDINAL) +
|
||||
if (cardinalOnly) emptyList() else action.diagonals.orEmpty().zip(DIAGONAL)
|
||||
return codes.filter { it.first != 0 }.map { (code, unit) -> unit to pcKeyLabel(context, code) }
|
||||
}
|
||||
|
||||
private fun View.drawDirectionLabels(canvas: Canvas, source: InputSurfaceSource?, cardinalOnly: Boolean, cx: Float, cy: Float,
|
||||
distance: Float, box: Float, paint: Paint): Boolean {
|
||||
val labels = directionLabels(context, source?.directions, cardinalOnly)
|
||||
paint.style = Paint.Style.FILL
|
||||
for ((unit, text) in labels) {
|
||||
paint.textSize = box * .6f
|
||||
val measured = paint.measureText(text)
|
||||
if (measured > box) paint.textSize *= box / measured
|
||||
canvas.drawText(text, cx + unit.first * distance, cy + unit.second * distance - (paint.ascent() + paint.descent()) / 2f, paint)
|
||||
}
|
||||
return labels.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int, paint: Paint) {
|
||||
val (fx, fy) = when (direction) { 0 -> 0f to -1f; 1 -> 1f to 0f; 2 -> 0f to 1f; else -> -1f to 0f }
|
||||
val path = Path()
|
||||
|
||||
@@ -109,6 +109,7 @@ import com.droiddeck.launcher.stores.itch.ItchUpdates
|
||||
import com.droiddeck.launcher.stores.gog.GogMaintenance
|
||||
import com.droiddeck.launcher.stores.retainOrder
|
||||
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
|
||||
import com.droiddeck.launcher.stores.entryTime
|
||||
import com.droiddeck.launcher.stores.sortGames
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -296,8 +297,8 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
|
||||
val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null }
|
||||
val sorted = sortGames(
|
||||
candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() },
|
||||
lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L },
|
||||
installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L },
|
||||
lastPlayed = { e -> entryTime(e.game?.lastPlayed, e.storeKey?.let { lastPlayed[it] }) },
|
||||
installedAt = { e -> entryTime(e.storeKey?.let { installedAt[it] }, e.game?.installedAt) },
|
||||
size = { e -> sizeOf(e) ?: 0L },
|
||||
)
|
||||
var stable by remember { mutableStateOf(emptyList<LibraryEntry>()) }
|
||||
|
||||
@@ -224,6 +224,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
ChoiceRow(host, "picture-position", stringResource(R.string.display_picture_position), null,
|
||||
listOf("top" to stringResource(R.string.display_position_top),
|
||||
"bottom" to stringResource(R.string.display_position_bottom),
|
||||
"left" to stringResource(R.string.display_position_left),
|
||||
"right" to stringResource(R.string.display_position_right),
|
||||
"center" to stringResource(R.string.display_position_center)),
|
||||
position, onPick = a.onPicturePosition)
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ class SecondScreenPresentation(
|
||||
context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard),
|
||||
icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") { showKeyboard() }))
|
||||
SecondScreenMode.DECK_CONTROLS -> model(swap, deckPanelTabs(context, deckPanel.value, ::selectDeckPanel))
|
||||
SecondScreenMode.DASHBOARD -> model(swap)
|
||||
SecondScreenMode.DASHBOARD, SecondScreenMode.CUSTOM_CONTROLS -> model(swap)
|
||||
SecondScreenMode.TERMINAL -> model(listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard,
|
||||
tag = "bottom-keyboard") { terminalPanel?.showKeyboard() }))
|
||||
else -> null
|
||||
|
||||
@@ -219,7 +219,6 @@ class DrawerActions(
|
||||
val frameGen: FrameGen.Mode,
|
||||
val lossless: Lossless.State,
|
||||
val oscMode: String,
|
||||
val onScreenButtonsVisible: Boolean,
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val micAvailable: Boolean = false,
|
||||
@@ -325,6 +324,7 @@ class DrawerActions(
|
||||
val onStop: () -> Unit,
|
||||
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
|
||||
val onClose: () -> Unit,
|
||||
val onDismissScreen: (() -> Unit)? = null,
|
||||
val components: ComponentsManager.Snapshot? = null,
|
||||
val systemPowerVr: Boolean = false,
|
||||
val onComponentsRefresh: () -> Unit = {},
|
||||
@@ -488,7 +488,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
) {
|
||||
SteamActionPill(
|
||||
stringResource(R.string.common_close), Icons.Outlined.Close, focus.track(page, "close"),
|
||||
) { host.open = null; a.onClose() }
|
||||
) { host.open = null; (a.onDismissScreen ?: a.onClose)() }
|
||||
if (roomyHeader) Spacer(Modifier.weight(1f))
|
||||
SteamActionPill(
|
||||
stringResource(R.string.stores_dl_pause), Icons.Outlined.Pause, focus.track(page, "pause"),
|
||||
|
||||
@@ -98,6 +98,7 @@ internal class HudData(
|
||||
val tempColor: (HudMetrics.TempSensor, Int) -> Color,
|
||||
val redC: (HudMetrics.TempSensor) -> Int?,
|
||||
val historySeconds: Int = 0,
|
||||
val graphEnd: Long = 0L,
|
||||
) {
|
||||
val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it }
|
||||
val hz: Int get() = if (refreshHz > 0) refreshHz else 60
|
||||
@@ -268,18 +269,26 @@ private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean =
|
||||
private val GRAPH_MIN_GAP = 3.dp
|
||||
|
||||
@Composable
|
||||
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier) {
|
||||
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier, end: Long = 0L) {
|
||||
Canvas(modifier) {
|
||||
if (values.size < 2 || hi <= lo) return@Canvas
|
||||
val stride = ceil(GRAPH_MIN_GAP.toPx() * (values.size - 1) / size.width).toInt().coerceAtLeast(1)
|
||||
val points = if (stride == 1) values else values.chunked(stride) { it.average().toFloat() }
|
||||
if (points.size < 2) return@Canvas
|
||||
// Buckets start at multiples of stride counted from the first sample ever drawn, so a sample keeps
|
||||
// its bucket as the window scrolls; counted from the window's start, every new frame re-paired them.
|
||||
val skip = Math.floorMod(values.size.toLong() - end, stride.toLong()).toInt()
|
||||
val path = Path()
|
||||
points.forEachIndexed { i, v ->
|
||||
val x = i / (points.size - 1f) * size.width
|
||||
val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
var drawn = 0
|
||||
var start = skip
|
||||
while (start < values.size) {
|
||||
val stop = minOf(start + stride, values.size)
|
||||
var sum = 0f
|
||||
for (i in start until stop) sum += values[i]
|
||||
val x = (start + stop - 1) / 2f / (values.size - 1f) * size.width
|
||||
val y = size.height - ((sum / (stop - start) - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
|
||||
if (drawn++ == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
start = stop
|
||||
}
|
||||
if (drawn < 2) return@Canvas
|
||||
drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||
drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||
}
|
||||
@@ -287,7 +296,7 @@ private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modif
|
||||
|
||||
@Composable
|
||||
private fun FrameGraph(d: HudData, accent: Color, height: Dp) {
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height))
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height), d.graphEnd)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -589,7 +598,7 @@ private fun ColumnScope.Pacing(d: HudData, accent: Color) {
|
||||
drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash)
|
||||
}
|
||||
}
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize())
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize(), d.graphEnd)
|
||||
for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) {
|
||||
Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color,
|
||||
Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp))
|
||||
|
||||
@@ -58,6 +58,7 @@ object Themes {
|
||||
const val RGB_SPEEDS = 10
|
||||
const val DEFAULT_RGB_SPEED = 3
|
||||
const val RGB_HUE_STEP = 3f
|
||||
const val WHITE_RED = "white_red"
|
||||
|
||||
var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED)
|
||||
private set
|
||||
@@ -127,6 +128,13 @@ object Themes {
|
||||
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
|
||||
good = Color(0xFF4CD37F),
|
||||
),
|
||||
Palette(
|
||||
WHITE_RED, R.string.theme_white_red, R.string.theme_white_red_detail,
|
||||
background = Color(0xFFF8F8F8), surface = Color(0xFFFFFFFF), surfaceVariant = Color(0xFFF0F0F0), line = Color(0xFFDDDDDD), line2 = Color(0xFFC8C8C8),
|
||||
onBackground = Color(0xFF191919), onSurfaceVariant = Color(0xFF555555),
|
||||
primary = Color(0xFFD62828), primary2 = Color(0xFFB71C1C), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFFD62828),
|
||||
good = Color(0xFF248548),
|
||||
),
|
||||
innerGamer(0f),
|
||||
ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail),
|
||||
)
|
||||
|
||||
@@ -1259,7 +1259,6 @@
|
||||
<string name="deck_tab_gamepad">يد التحكّم</string>
|
||||
<string name="deck_surface_stick">عصا التحكّم</string>
|
||||
<string name="deck_surface_trackpad">لوحة اللمس</string>
|
||||
<string name="deck_surface_switch">التبديل بين عصا التحكّم ولوحة اللمس</string>
|
||||
<string name="deck_close">إغلاق عناصر التحكّم على الشاشة الثانية</string>
|
||||
<string name="deck_open_steam_menu">فتح قائمة Steam</string>
|
||||
<string name="deck_right_trackpad">لوحة اللمس اليمنى</string>
|
||||
@@ -1874,13 +1873,6 @@
|
||||
<string name="bottom_downloads_shader">جارٍ تنزيل ذاكرة المظلِّلات المؤقتة</string>
|
||||
<string name="bottom_downloads_paused">متوقف مؤقتًا</string>
|
||||
<string name="bottom_downloads_queued">بانتظار البدء</string>
|
||||
<string name="bottom_page_notifications">الإشعارات</string>
|
||||
<string name="bottom_notifications_listener">الشاشة السفلية لـ DroidDeck</string>
|
||||
<string name="bottom_notifications_why">اعرض الرسائل والإشعارات الأخرى هنا أثناء اللعب. تبقى على هذه الشاشة فقط ولا تُحفظ. يطلب Android الإذن مرة واحدة: تُفتح صفحة الوصول إلى الإشعارات على هذه الشاشة؛ فعِّل DroidDeck هناك وستعود هذه الصفحة تلقائيًا.</string>
|
||||
<string name="bottom_notifications_allow">السماح بالوصول إلى الإشعارات</string>
|
||||
<string name="bottom_notifications_none">لا توجد إشعارات.</string>
|
||||
<string name="bottom_notifications_count">الإشعارات: %1$d</string>
|
||||
<string name="bottom_notifications_clear">مسح</string>
|
||||
<string name="bottom_page_screenshots">لقطات الشاشة</string>
|
||||
<string name="stats_fps_needs_hud">يتطلب FPS تشغيل واجهة الأداء</string>
|
||||
<string name="stats_speed">السرعة</string>
|
||||
@@ -2984,6 +2976,8 @@
|
||||
<string name="display_position_top">أعلى</string>
|
||||
<string name="display_position_bottom">أسفل</string>
|
||||
<string name="display_position_center">الوسط (افتراضي)</string>
|
||||
<string name="display_position_left">يسار</string>
|
||||
<string name="display_position_right">يمين</string>
|
||||
<string name="setup_tab_storage">التخزين</string>
|
||||
<string name="storage_measuring">جارٍ قياس التخزين…</string>
|
||||
<string name="storage_measuring_hint">يستغرق القياس الأول بضع ثوانٍ.</string>
|
||||
@@ -3817,4 +3811,7 @@
|
||||
<string name="input_reset_positions">إعادة تعيين المواضع</string>
|
||||
<string name="input_show_label">إظهار التسمية</string>
|
||||
<string name="input_layout_import_failed">تعذّر استيراد هذا التخطيط</string>
|
||||
|
||||
<string name="theme_white_red">ثلج قرمزي</string>
|
||||
|
||||
</resources>
|
||||
@@ -1308,7 +1308,6 @@
|
||||
<string name="deck_tab_gamepad">Control</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
|
||||
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
|
||||
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad derecho</string>
|
||||
@@ -1866,13 +1865,6 @@
|
||||
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
|
||||
<string name="bottom_downloads_paused">En pausa</string>
|
||||
<string name="bottom_downloads_queued">Esperando para empezar</string>
|
||||
<string name="bottom_page_notifications">Notificaciones</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
|
||||
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
|
||||
<string name="bottom_notifications_none">No hay notificaciones.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificaciones</string>
|
||||
<string name="bottom_notifications_clear">Borrar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de pantalla</string>
|
||||
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
|
||||
<string name="stats_speed">Velocidad</string>
|
||||
@@ -2836,6 +2828,8 @@
|
||||
<string name="display_position_top">Arriba</string>
|
||||
<string name="display_position_bottom">Abajo</string>
|
||||
<string name="display_position_center">Centro (predeterminado)</string>
|
||||
<string name="display_position_left">Izquierda</string>
|
||||
<string name="display_position_right">Derecha</string>
|
||||
<string name="setup_tab_storage">Almacenamiento</string>
|
||||
<string name="storage_measuring">Midiendo el almacenamiento…</string>
|
||||
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
|
||||
@@ -3673,4 +3667,6 @@
|
||||
<string name="input_reset_positions">Restablecer posiciones</string>
|
||||
<string name="input_show_label">Mostrar etiqueta</string>
|
||||
<string name="input_layout_import_failed">No se pudo importar esta disposición</string>
|
||||
<string name="theme_white_red">Nieve escarlata</string>
|
||||
<string name="theme_white_red_detail">Superficies blancas con acentos rojos.</string>
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Геймпад</string>
|
||||
<string name="deck_surface_stick">Стик</string>
|
||||
<string name="deck_surface_trackpad">Тракпад</string>
|
||||
<string name="deck_surface_switch">Превключване между стик и тракпад</string>
|
||||
<string name="deck_close">Затваряне на контролите за втория екран</string>
|
||||
<string name="deck_open_steam_menu">Отваряне на менюто на Steam</string>
|
||||
<string name="deck_right_trackpad">Десен тракпад</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Изтегляне на кеша на шейдърите</string>
|
||||
<string name="bottom_downloads_paused">На пауза</string>
|
||||
<string name="bottom_downloads_queued">Изчаква стартиране</string>
|
||||
<string name="bottom_page_notifications">Известия</string>
|
||||
<string name="bottom_notifications_listener">Долен екран на DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Показвайте тук съобщения и други известия, докато играете. Те остават само на този екран и не се запазват. Android пита веднъж: страницата за достъп до известията се отваря на този екран; включете DroidDeck там и тази страница ще се върне сама.</string>
|
||||
<string name="bottom_notifications_allow">Разрешаване на достъпа до известията</string>
|
||||
<string name="bottom_notifications_none">Няма известия.</string>
|
||||
<string name="bottom_notifications_count">Известия: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Изчисти</string>
|
||||
<string name="bottom_page_screenshots">Екранни снимки</string>
|
||||
<string name="stats_fps_needs_hud">За FPS трябва HUD да е включен</string>
|
||||
<string name="stats_speed">Скорост</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Горе</string>
|
||||
<string name="display_position_bottom">Долу</string>
|
||||
<string name="display_position_center">В средата (по подразбиране)</string>
|
||||
<string name="display_position_left">Наляво</string>
|
||||
<string name="display_position_right">Надясно</string>
|
||||
<string name="setup_tab_storage">Съхранение</string>
|
||||
<string name="storage_measuring">Измерване на съхранението…</string>
|
||||
<string name="storage_measuring_hint">Първият път отнема няколко секунди.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Нулиране на позициите</string>
|
||||
<string name="input_show_label">Показване на етикета</string>
|
||||
<string name="input_layout_import_failed">Това разположение не можа да бъде импортирано</string>
|
||||
|
||||
<string name="theme_white_red">Ален сняг</string>
|
||||
|
||||
</resources>
|
||||
@@ -1245,7 +1245,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Páčka</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Přepnout mezi páčkou a trackpadem</string>
|
||||
<string name="deck_close">Zavřít ovládání druhé obrazovky</string>
|
||||
<string name="deck_open_steam_menu">Otevřít nabídku Steamu</string>
|
||||
<string name="deck_right_trackpad">Pravý trackpad</string>
|
||||
@@ -1826,13 +1825,6 @@
|
||||
<string name="bottom_downloads_shader">Stahování mezipaměti shaderů</string>
|
||||
<string name="bottom_downloads_paused">Pozastaveno</string>
|
||||
<string name="bottom_downloads_queued">Čeká na spuštění</string>
|
||||
<string name="bottom_page_notifications">Oznámení</string>
|
||||
<string name="bottom_notifications_listener">Spodní obrazovka DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Zobrazujte zde zprávy a další oznámení během hraní. Zůstávají jen na této obrazovce a neukládají se. Android se zeptá jednou: na této obrazovce se otevře jeho stránka přístupu k oznámením; zapněte tam DroidDeck a tato stránka se sama vrátí.</string>
|
||||
<string name="bottom_notifications_allow">Povolit přístup k oznámením</string>
|
||||
<string name="bottom_notifications_none">Žádná oznámení.</string>
|
||||
<string name="bottom_notifications_count">Oznámení: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Vymazat</string>
|
||||
<string name="bottom_page_screenshots">Snímky obrazovky</string>
|
||||
<string name="stats_fps_needs_hud">FPS vyžaduje zapnutý HUD</string>
|
||||
<string name="stats_speed">Rychlost</string>
|
||||
@@ -2926,6 +2918,8 @@
|
||||
<string name="display_position_top">Nahoře</string>
|
||||
<string name="display_position_bottom">Dole</string>
|
||||
<string name="display_position_center">Uprostřed (výchozí)</string>
|
||||
<string name="display_position_left">Vlevo</string>
|
||||
<string name="display_position_right">Vpravo</string>
|
||||
<string name="setup_tab_storage">Úložiště</string>
|
||||
<string name="storage_measuring">Měření úložiště…</string>
|
||||
<string name="storage_measuring_hint">Poprvé to trvá pár sekund.</string>
|
||||
@@ -3739,4 +3733,7 @@
|
||||
<string name="input_reset_positions">Obnovit pozice</string>
|
||||
<string name="input_show_label">Zobrazit popisek</string>
|
||||
<string name="input_layout_import_failed">Toto rozložení nelze importovat</string>
|
||||
|
||||
<string name="theme_white_red">Šarlatový sníh</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Skift mellem stick og trackpad</string>
|
||||
<string name="deck_close">Luk kontrollerne til den anden skærm</string>
|
||||
<string name="deck_open_steam_menu">Åbn Steam-menuen</string>
|
||||
<string name="deck_right_trackpad">Højre trackpad</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Downloader shader-cache</string>
|
||||
<string name="bottom_downloads_paused">På pause</string>
|
||||
<string name="bottom_downloads_queued">Venter på at starte</string>
|
||||
<string name="bottom_page_notifications">Notifikationer</string>
|
||||
<string name="bottom_notifications_listener">DroidDecks nederste skærm</string>
|
||||
<string name="bottom_notifications_why">Vis beskeder og andre notifikationer her, mens du spiller. De bliver kun på denne skærm og gemmes ikke. Android spørger én gang: siden for notifikationsadgang åbnes på denne skærm; slå DroidDeck til der, så vender denne side selv tilbage.</string>
|
||||
<string name="bottom_notifications_allow">Tillad notifikationsadgang</string>
|
||||
<string name="bottom_notifications_none">Ingen notifikationer.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifikationer</string>
|
||||
<string name="bottom_notifications_clear">Ryd</string>
|
||||
<string name="bottom_page_screenshots">Skærmbilleder</string>
|
||||
<string name="stats_fps_needs_hud">FPS kræver, at HUD er slået til</string>
|
||||
<string name="stats_speed">Hastighed</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Øverst</string>
|
||||
<string name="display_position_bottom">Nederst</string>
|
||||
<string name="display_position_center">Midten (standard)</string>
|
||||
<string name="display_position_left">Venstre</string>
|
||||
<string name="display_position_right">Højre</string>
|
||||
<string name="setup_tab_storage">Lager</string>
|
||||
<string name="storage_measuring">Måler lager…</string>
|
||||
<string name="storage_measuring_hint">Første gang tager et par sekunder.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Nulstil placeringer</string>
|
||||
<string name="input_show_label">Vis etiket</string>
|
||||
<string name="input_layout_import_failed">Dette layout kunne ikke importeres</string>
|
||||
|
||||
<string name="theme_white_red">Skarlagensne</string>
|
||||
|
||||
</resources>
|
||||
@@ -1223,7 +1223,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Zwischen Stick und Trackpad wechseln</string>
|
||||
<string name="deck_close">Steuerung auf dem Zweitbildschirm schließen</string>
|
||||
<string name="deck_open_steam_menu">Steam-Menü öffnen</string>
|
||||
<string name="deck_right_trackpad">Rechtes Trackpad</string>
|
||||
@@ -1770,13 +1769,6 @@
|
||||
<string name="bottom_downloads_shader">Shader-Cache wird heruntergeladen</string>
|
||||
<string name="bottom_downloads_paused">Pausiert</string>
|
||||
<string name="bottom_downloads_queued">Wartet auf Start</string>
|
||||
<string name="bottom_page_notifications">Benachrichtigungen</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – unterer Bildschirm</string>
|
||||
<string name="bottom_notifications_why">Zeigt Nachrichten und andere Benachrichtigungen hier an, während du spielst. Sie bleiben nur auf diesem Bildschirm und werden nicht gespeichert. Android fragt einmal: Die Seite für den Benachrichtigungszugriff öffnet sich auf diesem Bildschirm; aktiviere dort DroidDeck, dann kehrt diese Seite von selbst zurück.</string>
|
||||
<string name="bottom_notifications_allow">Benachrichtigungszugriff erlauben</string>
|
||||
<string name="bottom_notifications_none">Keine Benachrichtigungen.</string>
|
||||
<string name="bottom_notifications_count">%1$d Benachrichtigungen</string>
|
||||
<string name="bottom_notifications_clear">Löschen</string>
|
||||
<string name="bottom_page_screenshots">Screenshots</string>
|
||||
<string name="stats_fps_needs_hud">FPS benötigt das aktive HUD</string>
|
||||
<string name="stats_speed">Geschwindigkeit</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Oben</string>
|
||||
<string name="display_position_bottom">Unten</string>
|
||||
<string name="display_position_center">Mitte (Standard)</string>
|
||||
<string name="display_position_left">Links</string>
|
||||
<string name="display_position_right">Rechts</string>
|
||||
<string name="setup_tab_storage">Speicher</string>
|
||||
<string name="storage_measuring">Speicher wird erfasst…</string>
|
||||
<string name="storage_measuring_hint">Die erste Erfassung dauert ein paar Sekunden.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Positionen zurücksetzen</string>
|
||||
<string name="input_show_label">Beschriftung anzeigen</string>
|
||||
<string name="input_layout_import_failed">Dieses Layout konnte nicht importiert werden</string>
|
||||
|
||||
<string name="theme_white_red">Scharlachroter Schnee</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Χειριστήριο</string>
|
||||
<string name="deck_surface_stick">Μοχλός</string>
|
||||
<string name="deck_surface_trackpad">Πινακίδα αφής</string>
|
||||
<string name="deck_surface_switch">Εναλλαγή μεταξύ μοχλού και πινακίδας αφής</string>
|
||||
<string name="deck_close">Κλείσιμο χειριστηρίων δεύτερης οθόνης</string>
|
||||
<string name="deck_open_steam_menu">Άνοιγμα μενού Steam</string>
|
||||
<string name="deck_right_trackpad">Δεξιά πινακίδα αφής</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Λήψη cache shader</string>
|
||||
<string name="bottom_downloads_paused">Σε παύση</string>
|
||||
<string name="bottom_downloads_queued">Αναμονή για έναρξη</string>
|
||||
<string name="bottom_page_notifications">Ειδοποιήσεις</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – κάτω οθόνη</string>
|
||||
<string name="bottom_notifications_why">Εμφάνιση μηνυμάτων και άλλων ειδοποιήσεων εδώ ενώ παίζετε. Παραμένουν μόνο σε αυτή την οθόνη και δεν αποθηκεύονται. Το Android ρωτά μία φορά: η σελίδα πρόσβασης ειδοποιήσεων ανοίγει σε αυτή την οθόνη· ενεργοποιήστε εκεί το DroidDeck και αυτή η σελίδα επιστρέφει μόνη της.</string>
|
||||
<string name="bottom_notifications_allow">Αποδοχή πρόσβασης ειδοποιήσεων</string>
|
||||
<string name="bottom_notifications_none">Δεν υπάρχουν ειδοποιήσεις.</string>
|
||||
<string name="bottom_notifications_count">%1$d ειδοποιήσεις</string>
|
||||
<string name="bottom_notifications_clear">Απαλοιφή</string>
|
||||
<string name="bottom_page_screenshots">Στιγμιότυπα οθόνης</string>
|
||||
<string name="stats_fps_needs_hud">Τα FPS απαιτούν ενεργό HUD</string>
|
||||
<string name="stats_speed">Ταχύτητα</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Πάνω</string>
|
||||
<string name="display_position_bottom">Κάτω</string>
|
||||
<string name="display_position_center">Κέντρο (προεπιλογή)</string>
|
||||
<string name="display_position_left">Αριστερά</string>
|
||||
<string name="display_position_right">Δεξιά</string>
|
||||
<string name="setup_tab_storage">Αποθήκευση</string>
|
||||
<string name="storage_measuring">Μέτρηση αποθήκευσης…</string>
|
||||
<string name="storage_measuring_hint">Την πρώτη φορά παίρνει λίγα δευτερόλεπτα.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Επαναφορά θέσεων</string>
|
||||
<string name="input_show_label">Εμφάνιση ετικέτας</string>
|
||||
<string name="input_layout_import_failed">Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης</string>
|
||||
|
||||
<string name="theme_white_red">Κατακόκκινο χιόνι</string>
|
||||
|
||||
</resources>
|
||||
@@ -1238,7 +1238,6 @@
|
||||
<string name="deck_tab_gamepad">Mando</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
|
||||
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
|
||||
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad derecho</string>
|
||||
@@ -1802,13 +1801,6 @@
|
||||
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
|
||||
<string name="bottom_downloads_paused">En pausa</string>
|
||||
<string name="bottom_downloads_queued">Esperando para empezar</string>
|
||||
<string name="bottom_page_notifications">Notificaciones</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
|
||||
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
|
||||
<string name="bottom_notifications_none">No hay notificaciones.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificaciones</string>
|
||||
<string name="bottom_notifications_clear">Borrar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de pantalla</string>
|
||||
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
|
||||
<string name="stats_speed">Velocidad</string>
|
||||
@@ -2897,6 +2889,8 @@
|
||||
<string name="display_position_top">Arriba</string>
|
||||
<string name="display_position_bottom">Abajo</string>
|
||||
<string name="display_position_center">Centro (predeterminado)</string>
|
||||
<string name="display_position_left">Izquierda</string>
|
||||
<string name="display_position_right">Derecha</string>
|
||||
<string name="setup_tab_storage">Almacenamiento</string>
|
||||
<string name="storage_measuring">Midiendo el almacenamiento…</string>
|
||||
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
|
||||
@@ -3700,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Restablecer posiciones</string>
|
||||
<string name="input_show_label">Mostrar etiqueta</string>
|
||||
<string name="input_layout_import_failed">No se ha podido importar esta disposición</string>
|
||||
|
||||
<string name="theme_white_red">Nieve escarlata</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Peliohjain</string>
|
||||
<string name="deck_surface_stick">Tatti</string>
|
||||
<string name="deck_surface_trackpad">Kosketuslevy</string>
|
||||
<string name="deck_surface_switch">Vaihda tatin ja kosketuslevyn välillä</string>
|
||||
<string name="deck_close">Sulje toisen näytön ohjaimet</string>
|
||||
<string name="deck_open_steam_menu">Avaa Steam-valikko</string>
|
||||
<string name="deck_right_trackpad">Oikea kosketuslevy</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Ladataan sävytinvälimuistia</string>
|
||||
<string name="bottom_downloads_paused">Keskeytetty</string>
|
||||
<string name="bottom_downloads_queued">Odottaa alkamista</string>
|
||||
<string name="bottom_page_notifications">Ilmoitukset</string>
|
||||
<string name="bottom_notifications_listener">DroidDeckin alanäyttö</string>
|
||||
<string name="bottom_notifications_why">Näytä viestit ja muut ilmoitukset täällä pelatessasi. Ne näkyvät vain tällä näytöllä, eikä niitä tallenneta. Android kysyy kerran: sen ilmoitusten käyttöoikeussivu avautuu tälle näytölle; kytke DroidDeck siellä päälle, niin tämä sivu palaa itsestään.</string>
|
||||
<string name="bottom_notifications_allow">Salli ilmoitusten käyttö</string>
|
||||
<string name="bottom_notifications_none">Ei ilmoituksia.</string>
|
||||
<string name="bottom_notifications_count">Ilmoituksia: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Tyhjennä</string>
|
||||
<string name="bottom_page_screenshots">Kuvakaappaukset</string>
|
||||
<string name="stats_fps_needs_hud">FPS vaatii HUD:n päälle</string>
|
||||
<string name="stats_speed">Nopeus</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Ylhäällä</string>
|
||||
<string name="display_position_bottom">Alhaalla</string>
|
||||
<string name="display_position_center">Keskellä (oletus)</string>
|
||||
<string name="display_position_left">Vasen</string>
|
||||
<string name="display_position_right">Oikea</string>
|
||||
<string name="setup_tab_storage">Tallennustila</string>
|
||||
<string name="storage_measuring">Mitataan tallennustilaa…</string>
|
||||
<string name="storage_measuring_hint">Ensimmäisellä kerralla kestää muutaman sekunnin.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Palauta sijainnit</string>
|
||||
<string name="input_show_label">Näytä nimi</string>
|
||||
<string name="input_layout_import_failed">Tätä asettelua ei voitu tuoda</string>
|
||||
|
||||
<string name="theme_white_red">Helakanpunainen lumi</string>
|
||||
|
||||
</resources>
|
||||
@@ -1238,7 +1238,6 @@
|
||||
<string name="deck_tab_gamepad">Manette</string>
|
||||
<string name="deck_surface_stick">Joystick</string>
|
||||
<string name="deck_surface_trackpad">Pavé tactile</string>
|
||||
<string name="deck_surface_switch">Basculer entre joystick et pavé tactile</string>
|
||||
<string name="deck_close">Fermer les commandes du deuxième écran</string>
|
||||
<string name="deck_open_steam_menu">Ouvrir le menu Steam</string>
|
||||
<string name="deck_right_trackpad">Pavé tactile droit</string>
|
||||
@@ -1802,13 +1801,6 @@
|
||||
<string name="bottom_downloads_shader">Téléchargement du cache de shaders</string>
|
||||
<string name="bottom_downloads_paused">En pause</string>
|
||||
<string name="bottom_downloads_queued">En attente de démarrage</string>
|
||||
<string name="bottom_page_notifications">Notifications</string>
|
||||
<string name="bottom_notifications_listener">Écran inférieur DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Affichez ici les messages et autres notifications pendant que vous jouez. Ils restent uniquement sur cet écran et ne sont pas enregistrés. Android ne le demande qu\'une fois : sa page d\'accès aux notifications s\'ouvre sur cet écran ; activez-y DroidDeck et cette page reviendra d\'elle-même.</string>
|
||||
<string name="bottom_notifications_allow">Autoriser l\'accès aux notifications</string>
|
||||
<string name="bottom_notifications_none">Aucune notification.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifications</string>
|
||||
<string name="bottom_notifications_clear">Effacer</string>
|
||||
<string name="bottom_page_screenshots">Captures d\'écran</string>
|
||||
<string name="stats_fps_needs_hud">Les FPS nécessitent d\'activer le HUD</string>
|
||||
<string name="stats_speed">Vitesse</string>
|
||||
@@ -2897,6 +2889,8 @@
|
||||
<string name="display_position_top">Haut</string>
|
||||
<string name="display_position_bottom">Bas</string>
|
||||
<string name="display_position_center">Centre (par défaut)</string>
|
||||
<string name="display_position_left">Gauche</string>
|
||||
<string name="display_position_right">Droite</string>
|
||||
<string name="setup_tab_storage">Stockage</string>
|
||||
<string name="storage_measuring">Mesure du stockage…</string>
|
||||
<string name="storage_measuring_hint">Cela prend quelques secondes la première fois.</string>
|
||||
@@ -3700,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Réinitialiser les positions</string>
|
||||
<string name="input_show_label">Afficher le libellé</string>
|
||||
<string name="input_layout_import_failed">Impossible d’importer cette disposition</string>
|
||||
|
||||
<string name="theme_white_red">Neige écarlate</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">गेमपैड</string>
|
||||
<string name="deck_surface_stick">स्टिक</string>
|
||||
<string name="deck_surface_trackpad">ट्रैकपैड</string>
|
||||
<string name="deck_surface_switch">स्टिक और ट्रैकपैड के बीच बदलें</string>
|
||||
<string name="deck_close">दूसरी स्क्रीन के कंट्रोल बंद करें</string>
|
||||
<string name="deck_open_steam_menu">Steam मेनू खोलें</string>
|
||||
<string name="deck_right_trackpad">दायां ट्रैकपैड</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">शेडर कैश डाउनलोड हो रहा है</string>
|
||||
<string name="bottom_downloads_paused">रुका हुआ</string>
|
||||
<string name="bottom_downloads_queued">शुरू होने का इंतज़ार</string>
|
||||
<string name="bottom_page_notifications">नोटिफ़िकेशन</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck नीचे की स्क्रीन</string>
|
||||
<string name="bottom_notifications_why">खेलते समय मैसेज और अन्य नोटिफ़िकेशन यहां दिखाएं। वे सिर्फ़ इसी स्क्रीन पर रहते हैं और सेव नहीं होते। Android एक बार पूछता है: उसका नोटिफ़िकेशन ऐक्सेस पेज इसी स्क्रीन पर खुलता है; वहां DroidDeck चालू करें और यह पेज अपने-आप वापस आ जाएगा।</string>
|
||||
<string name="bottom_notifications_allow">नोटिफ़िकेशन ऐक्सेस की अनुमति दें</string>
|
||||
<string name="bottom_notifications_none">कोई नोटिफ़िकेशन नहीं।</string>
|
||||
<string name="bottom_notifications_count">%1$d नोटिफ़िकेशन</string>
|
||||
<string name="bottom_notifications_clear">साफ़ करें</string>
|
||||
<string name="bottom_page_screenshots">स्क्रीनशॉट</string>
|
||||
<string name="stats_fps_needs_hud">FPS के लिए HUD चालू होना चाहिए</string>
|
||||
<string name="stats_speed">स्पीड</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">ऊपर</string>
|
||||
<string name="display_position_bottom">नीचे</string>
|
||||
<string name="display_position_center">बीच में (डिफ़ॉल्ट)</string>
|
||||
<string name="display_position_left">बाएँ</string>
|
||||
<string name="display_position_right">दाएँ</string>
|
||||
<string name="setup_tab_storage">स्टोरेज</string>
|
||||
<string name="storage_measuring">स्टोरेज मापा जा रहा है…</string>
|
||||
<string name="storage_measuring_hint">पहली बार कुछ सेकंड लगते हैं।</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">स्थितियाँ रीसेट करें</string>
|
||||
<string name="input_show_label">लेबल दिखाएँ</string>
|
||||
<string name="input_layout_import_failed">यह लेआउट इम्पोर्ट नहीं किया जा सका</string>
|
||||
|
||||
<string name="theme_white_red">किरमिज़ी बर्फ</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Kontroller</string>
|
||||
<string name="deck_surface_stick">Kar</string>
|
||||
<string name="deck_surface_trackpad">Érintőpad</string>
|
||||
<string name="deck_surface_switch">Váltás a kar és az érintőpad között</string>
|
||||
<string name="deck_close">Második képernyős vezérlők bezárása</string>
|
||||
<string name="deck_open_steam_menu">Steam-menü megnyitása</string>
|
||||
<string name="deck_right_trackpad">Jobb érintőpad</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Shader-gyorsítótár letöltése</string>
|
||||
<string name="bottom_downloads_paused">Szüneteltetve</string>
|
||||
<string name="bottom_downloads_queued">Indításra vár</string>
|
||||
<string name="bottom_page_notifications">Értesítések</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck alsó képernyő</string>
|
||||
<string name="bottom_notifications_why">Itt jelenítheti meg az üzeneteket és más értesítéseket játék közben. Csak ezen a képernyőn láthatók, és nem kerülnek mentésre. Az Android egyszer kérdez rá: az értesítési hozzáférés oldala ezen a képernyőn nyílik meg; kapcsolja be ott a DroidDecket, és ez az oldal magától visszatér.</string>
|
||||
<string name="bottom_notifications_allow">Értesítési hozzáférés engedélyezése</string>
|
||||
<string name="bottom_notifications_none">Nincsenek értesítések.</string>
|
||||
<string name="bottom_notifications_count">%1$d értesítés</string>
|
||||
<string name="bottom_notifications_clear">Törlés</string>
|
||||
<string name="bottom_page_screenshots">Képernyőképek</string>
|
||||
<string name="stats_fps_needs_hud">Az FPS-hez be kell kapcsolni a HUD-ot</string>
|
||||
<string name="stats_speed">Sebesség</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Fent</string>
|
||||
<string name="display_position_bottom">Lent</string>
|
||||
<string name="display_position_center">Középen (alapértelmezett)</string>
|
||||
<string name="display_position_left">Bal</string>
|
||||
<string name="display_position_right">Jobb</string>
|
||||
<string name="setup_tab_storage">Tárhely</string>
|
||||
<string name="storage_measuring">Tárhely mérése…</string>
|
||||
<string name="storage_measuring_hint">Első alkalommal pár másodpercig tart.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Pozíciók visszaállítása</string>
|
||||
<string name="input_show_label">Címke megjelenítése</string>
|
||||
<string name="input_layout_import_failed">Ez az elrendezés nem importálható</string>
|
||||
|
||||
<string name="theme_white_red">Skarlátvörös hó</string>
|
||||
|
||||
</resources>
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stik</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Beralih antara stik dan trackpad</string>
|
||||
<string name="deck_close">Tutup kontrol layar kedua</string>
|
||||
<string name="deck_open_steam_menu">Buka menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad kanan</string>
|
||||
@@ -1754,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">Mengunduh cache shader</string>
|
||||
<string name="bottom_downloads_paused">Dijeda</string>
|
||||
<string name="bottom_downloads_queued">Menunggu dimulai</string>
|
||||
<string name="bottom_page_notifications">Notifikasi</string>
|
||||
<string name="bottom_notifications_listener">Layar bawah DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Tampilkan pesan dan notifikasi lain di sini saat Anda bermain. Notifikasi hanya muncul di layar ini dan tidak disimpan. Android hanya meminta sekali: halaman akses notifikasinya terbuka di layar ini; aktifkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
|
||||
<string name="bottom_notifications_allow">Izinkan akses notifikasi</string>
|
||||
<string name="bottom_notifications_none">Tidak ada notifikasi.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifikasi</string>
|
||||
<string name="bottom_notifications_clear">Hapus</string>
|
||||
<string name="bottom_page_screenshots">Tangkapan layar</string>
|
||||
<string name="stats_fps_needs_hud">FPS memerlukan HUD aktif</string>
|
||||
<string name="stats_speed">Kecepatan</string>
|
||||
@@ -2839,6 +2831,8 @@
|
||||
<string name="display_position_top">Atas</string>
|
||||
<string name="display_position_bottom">Bawah</string>
|
||||
<string name="display_position_center">Tengah (bawaan)</string>
|
||||
<string name="display_position_left">Kiri</string>
|
||||
<string name="display_position_right">Kanan</string>
|
||||
<string name="setup_tab_storage">Penyimpanan</string>
|
||||
<string name="storage_measuring">Mengukur penyimpanan…</string>
|
||||
<string name="storage_measuring_hint">Butuh beberapa detik pada kali pertama.</string>
|
||||
@@ -3631,4 +3625,7 @@
|
||||
<string name="input_reset_positions">Atur ulang posisi</string>
|
||||
<string name="input_show_label">Tampilkan label</string>
|
||||
<string name="input_layout_import_failed">Tata letak ini tidak dapat diimpor</string>
|
||||
|
||||
<string name="theme_white_red">Salju Kirmizi</string>
|
||||
|
||||
</resources>
|
||||
@@ -1230,7 +1230,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Levetta</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Alterna tra levetta e trackpad</string>
|
||||
<string name="deck_close">Chiudi i controlli sul secondo schermo</string>
|
||||
<string name="deck_open_steam_menu">Apri il menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad destro</string>
|
||||
@@ -1794,13 +1793,6 @@
|
||||
<string name="bottom_downloads_shader">Download della cache degli shader</string>
|
||||
<string name="bottom_downloads_paused">In pausa</string>
|
||||
<string name="bottom_downloads_queued">In attesa di avvio</string>
|
||||
<string name="bottom_page_notifications">Notifiche</string>
|
||||
<string name="bottom_notifications_listener">Schermo inferiore DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Mostra qui messaggi e altre notifiche mentre giochi. Restano solo su questo schermo e non vengono salvate. Android lo chiede una sola volta: la sua pagina di accesso alle notifiche si apre su questo schermo; attiva DroidDeck lì e questa pagina torna da sola.</string>
|
||||
<string name="bottom_notifications_allow">Consenti accesso alle notifiche</string>
|
||||
<string name="bottom_notifications_none">Nessuna notifica.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifiche</string>
|
||||
<string name="bottom_notifications_clear">Cancella</string>
|
||||
<string name="bottom_page_screenshots">Screenshot</string>
|
||||
<string name="stats_fps_needs_hud">Gli FPS richiedono l\'HUD attivo</string>
|
||||
<string name="stats_speed">Velocità</string>
|
||||
@@ -2897,6 +2889,8 @@
|
||||
<string name="display_position_top">In alto</string>
|
||||
<string name="display_position_bottom">In basso</string>
|
||||
<string name="display_position_center">Centro (predefinito)</string>
|
||||
<string name="display_position_left">Sinistra</string>
|
||||
<string name="display_position_right">Destra</string>
|
||||
<string name="setup_tab_storage">Archiviazione</string>
|
||||
<string name="storage_measuring">Misurazione dell\'archiviazione…</string>
|
||||
<string name="storage_measuring_hint">La prima volta richiede qualche secondo.</string>
|
||||
@@ -3700,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Ripristina posizioni</string>
|
||||
<string name="input_show_label">Mostra etichetta</string>
|
||||
<string name="input_layout_import_failed">Impossibile importare questo layout</string>
|
||||
|
||||
<string name="theme_white_red">Neve scarlatta</string>
|
||||
|
||||
</resources>
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">ゲームパッド</string>
|
||||
<string name="deck_surface_stick">スティック</string>
|
||||
<string name="deck_surface_trackpad">トラックパッド</string>
|
||||
<string name="deck_surface_switch">スティックとトラックパッドを切り替え</string>
|
||||
<string name="deck_close">セカンドスクリーンコントロールを閉じる</string>
|
||||
<string name="deck_open_steam_menu">Steamメニューを開く</string>
|
||||
<string name="deck_right_trackpad">右トラックパッド</string>
|
||||
@@ -1754,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">シェーダーキャッシュをダウンロード中</string>
|
||||
<string name="bottom_downloads_paused">一時停止中</string>
|
||||
<string name="bottom_downloads_queued">開始待ち</string>
|
||||
<string name="bottom_page_notifications">通知</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck下部画面</string>
|
||||
<string name="bottom_notifications_why">プレイ中、メッセージなどの通知をここに表示します。通知はこの画面にのみ表示され、保存されません。Androidによる確認は一度だけです。この画面に通知へのアクセスのページが開くので、そこでDroidDeckをオンにすると、このページに自動的に戻ります。</string>
|
||||
<string name="bottom_notifications_allow">通知へのアクセスを許可</string>
|
||||
<string name="bottom_notifications_none">通知はありません。</string>
|
||||
<string name="bottom_notifications_count">%1$d件の通知</string>
|
||||
<string name="bottom_notifications_clear">クリア</string>
|
||||
<string name="bottom_page_screenshots">スクリーンショット</string>
|
||||
<string name="stats_fps_needs_hud">FPSの表示にはHUDをオンにする必要があります</string>
|
||||
<string name="stats_speed">速度</string>
|
||||
@@ -2839,6 +2831,8 @@
|
||||
<string name="display_position_top">上</string>
|
||||
<string name="display_position_bottom">下</string>
|
||||
<string name="display_position_center">中央(既定)</string>
|
||||
<string name="display_position_left">左</string>
|
||||
<string name="display_position_right">右</string>
|
||||
<string name="setup_tab_storage">ストレージ</string>
|
||||
<string name="storage_measuring">ストレージを集計しています…</string>
|
||||
<string name="storage_measuring_hint">初回の集計には数秒かかります。</string>
|
||||
@@ -3631,4 +3625,7 @@
|
||||
<string name="input_reset_positions">位置をリセット</string>
|
||||
<string name="input_show_label">ラベルを表示</string>
|
||||
<string name="input_layout_import_failed">このレイアウトをインポートできませんでした</string>
|
||||
|
||||
<string name="theme_white_red">緋色の雪</string>
|
||||
|
||||
</resources>
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">게임패드</string>
|
||||
<string name="deck_surface_stick">스틱</string>
|
||||
<string name="deck_surface_trackpad">터치패드</string>
|
||||
<string name="deck_surface_switch">스틱과 터치패드 간 전환</string>
|
||||
<string name="deck_close">보조 화면 컨트롤 닫기</string>
|
||||
<string name="deck_open_steam_menu">Steam 메뉴 열기</string>
|
||||
<string name="deck_right_trackpad">오른쪽 터치패드</string>
|
||||
@@ -1754,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">셰이더 캐시 다운로드 중</string>
|
||||
<string name="bottom_downloads_paused">일시 중지됨</string>
|
||||
<string name="bottom_downloads_queued">시작 대기 중</string>
|
||||
<string name="bottom_page_notifications">알림</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck 하단 화면</string>
|
||||
<string name="bottom_notifications_why">플레이하는 동안 메시지와 기타 알림을 여기에 표시합니다. 알림은 이 화면에만 표시되며 저장되지 않습니다. Android에서 한 번만 묻습니다. 이 화면에 알림 접근 권한 페이지가 열리면 DroidDeck을 켜세요. 그러면 이 페이지로 자동으로 돌아옵니다.</string>
|
||||
<string name="bottom_notifications_allow">알림 접근 허용</string>
|
||||
<string name="bottom_notifications_none">알림이 없습니다.</string>
|
||||
<string name="bottom_notifications_count">알림 %1$d개</string>
|
||||
<string name="bottom_notifications_clear">지우기</string>
|
||||
<string name="bottom_page_screenshots">스크린샷</string>
|
||||
<string name="stats_fps_needs_hud">FPS를 보려면 HUD를 켜야 합니다</string>
|
||||
<string name="stats_speed">속도</string>
|
||||
@@ -2839,6 +2831,8 @@
|
||||
<string name="display_position_top">위</string>
|
||||
<string name="display_position_bottom">아래</string>
|
||||
<string name="display_position_center">가운데(기본)</string>
|
||||
<string name="display_position_left">왼쪽</string>
|
||||
<string name="display_position_right">오른쪽</string>
|
||||
<string name="setup_tab_storage">저장 공간</string>
|
||||
<string name="storage_measuring">저장 공간 계산 중…</string>
|
||||
<string name="storage_measuring_hint">처음 계산할 때는 몇 초 걸립니다.</string>
|
||||
@@ -3631,4 +3625,7 @@
|
||||
<string name="input_reset_positions">위치 초기화</string>
|
||||
<string name="input_show_label">라벨 표시</string>
|
||||
<string name="input_layout_import_failed">이 레이아웃을 가져올 수 없습니다</string>
|
||||
|
||||
<string name="theme_white_red">진홍빛 눈</string>
|
||||
|
||||
</resources>
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stik</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Tukar antara stik dan trackpad</string>
|
||||
<string name="deck_close">Tutup kawalan skrin kedua</string>
|
||||
<string name="deck_open_steam_menu">Buka menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad kanan</string>
|
||||
@@ -1754,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">Memuat turun cache shader</string>
|
||||
<string name="bottom_downloads_paused">Dijeda</string>
|
||||
<string name="bottom_downloads_queued">Menunggu untuk bermula</string>
|
||||
<string name="bottom_page_notifications">Pemberitahuan</string>
|
||||
<string name="bottom_notifications_listener">Skrin bawah DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Paparkan mesej dan pemberitahuan lain di sini semasa anda bermain. Ia hanya kekal pada skrin ini dan tidak disimpan. Android bertanya sekali sahaja: halaman akses pemberitahuannya dibuka pada skrin ini; hidupkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
|
||||
<string name="bottom_notifications_allow">Benarkan akses pemberitahuan</string>
|
||||
<string name="bottom_notifications_none">Tiada pemberitahuan.</string>
|
||||
<string name="bottom_notifications_count">%1$d pemberitahuan</string>
|
||||
<string name="bottom_notifications_clear">Kosongkan</string>
|
||||
<string name="bottom_page_screenshots">Tangkapan skrin</string>
|
||||
<string name="stats_fps_needs_hud">FPS memerlukan HUD dihidupkan</string>
|
||||
<string name="stats_speed">Kelajuan</string>
|
||||
@@ -2839,6 +2831,8 @@
|
||||
<string name="display_position_top">Atas</string>
|
||||
<string name="display_position_bottom">Bawah</string>
|
||||
<string name="display_position_center">Tengah (lalai)</string>
|
||||
<string name="display_position_left">Kiri</string>
|
||||
<string name="display_position_right">Kanan</string>
|
||||
<string name="setup_tab_storage">Storan</string>
|
||||
<string name="storage_measuring">Mengukur storan…</string>
|
||||
<string name="storage_measuring_hint">Mengambil beberapa saat pada kali pertama.</string>
|
||||
@@ -3631,4 +3625,7 @@
|
||||
<string name="input_reset_positions">Tetapkan semula kedudukan</string>
|
||||
<string name="input_show_label">Tunjukkan label</string>
|
||||
<string name="input_layout_import_failed">Susun atur ini tidak dapat diimport</string>
|
||||
|
||||
<string name="theme_white_red">Salji Merah Skarlet</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Kontroller</string>
|
||||
<string name="deck_surface_stick">Stikke</string>
|
||||
<string name="deck_surface_trackpad">Styreflate</string>
|
||||
<string name="deck_surface_switch">Bytt mellom stikke og styreflate</string>
|
||||
<string name="deck_close">Lukk kontroller for andre skjerm</string>
|
||||
<string name="deck_open_steam_menu">Åpne Steam-menyen</string>
|
||||
<string name="deck_right_trackpad">Høyre styreflate</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Laster ned shaderbuffer</string>
|
||||
<string name="bottom_downloads_paused">Pauset</string>
|
||||
<string name="bottom_downloads_queued">Venter på å starte</string>
|
||||
<string name="bottom_page_notifications">Varslinger</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck nedre skjerm</string>
|
||||
<string name="bottom_notifications_why">Vis meldinger og andre varslinger her mens du spiller. De vises bare på denne skjermen og lagres ikke. Android spør én gang: siden for varseltilgang åpnes på denne skjermen; slå på DroidDeck der, så kommer denne siden tilbake av seg selv.</string>
|
||||
<string name="bottom_notifications_allow">Tillat varseltilgang</string>
|
||||
<string name="bottom_notifications_none">Ingen varslinger.</string>
|
||||
<string name="bottom_notifications_count">%1$d varslinger</string>
|
||||
<string name="bottom_notifications_clear">Fjern</string>
|
||||
<string name="bottom_page_screenshots">Skjermbilder</string>
|
||||
<string name="stats_fps_needs_hud">FPS krever at HUD-en er på</string>
|
||||
<string name="stats_speed">Fart</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Øverst</string>
|
||||
<string name="display_position_bottom">Nederst</string>
|
||||
<string name="display_position_center">Midten (standard)</string>
|
||||
<string name="display_position_left">Venstre</string>
|
||||
<string name="display_position_right">Høyre</string>
|
||||
<string name="setup_tab_storage">Lagring</string>
|
||||
<string name="storage_measuring">Måler lagring…</string>
|
||||
<string name="storage_measuring_hint">Første gang tar noen sekunder.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Tilbakestill plasseringer</string>
|
||||
<string name="input_show_label">Vis etikett</string>
|
||||
<string name="input_layout_import_failed">Kunne ikke importere dette oppsettet</string>
|
||||
|
||||
<string name="theme_white_red">Skarlagenssnø</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Wisselen tussen stick en trackpad</string>
|
||||
<string name="deck_close">Bediening op tweede scherm sluiten</string>
|
||||
<string name="deck_open_steam_menu">Steam-menu openen</string>
|
||||
<string name="deck_right_trackpad">Rechtertrackpad</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Shadercache downloaden</string>
|
||||
<string name="bottom_downloads_paused">Gepauzeerd</string>
|
||||
<string name="bottom_downloads_queued">Wacht om te starten</string>
|
||||
<string name="bottom_page_notifications">Meldingen</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck onderste scherm</string>
|
||||
<string name="bottom_notifications_why">Toon berichten en andere meldingen hier terwijl je speelt. Ze blijven alleen op dit scherm en worden niet opgeslagen. Android vraagt dit één keer: de pagina voor meldingstoegang opent op dit scherm; zet DroidDeck daar aan en deze pagina komt vanzelf terug.</string>
|
||||
<string name="bottom_notifications_allow">Meldingstoegang toestaan</string>
|
||||
<string name="bottom_notifications_none">Geen meldingen.</string>
|
||||
<string name="bottom_notifications_count">%1$d meldingen</string>
|
||||
<string name="bottom_notifications_clear">Wissen</string>
|
||||
<string name="bottom_page_screenshots">Screenshots</string>
|
||||
<string name="stats_fps_needs_hud">Voor FPS moet de HUD aan staan</string>
|
||||
<string name="stats_speed">Snelheid</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Boven</string>
|
||||
<string name="display_position_bottom">Onder</string>
|
||||
<string name="display_position_center">Midden (standaard)</string>
|
||||
<string name="display_position_left">Links</string>
|
||||
<string name="display_position_right">Rechts</string>
|
||||
<string name="setup_tab_storage">Opslag</string>
|
||||
<string name="storage_measuring">Opslag meten…</string>
|
||||
<string name="storage_measuring_hint">De eerste keer duurt een paar seconden.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Posities herstellen</string>
|
||||
<string name="input_show_label">Label tonen</string>
|
||||
<string name="input_layout_import_failed">Deze indeling kan niet worden geïmporteerd</string>
|
||||
|
||||
<string name="theme_white_red">Scharlakenrode sneeuw</string>
|
||||
|
||||
</resources>
|
||||
@@ -1245,7 +1245,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Gałka</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Przełącz między gałką a trackpadem</string>
|
||||
<string name="deck_close">Zamknij sterowanie na drugim ekranie</string>
|
||||
<string name="deck_open_steam_menu">Otwórz menu Steam</string>
|
||||
<string name="deck_right_trackpad">Prawy trackpad</string>
|
||||
@@ -1826,13 +1825,6 @@
|
||||
<string name="bottom_downloads_shader">Pobieranie pamięci podręcznej shaderów</string>
|
||||
<string name="bottom_downloads_paused">Wstrzymano</string>
|
||||
<string name="bottom_downloads_queued">Oczekuje na rozpoczęcie</string>
|
||||
<string name="bottom_page_notifications">Powiadomienia</string>
|
||||
<string name="bottom_notifications_listener">Dolny ekran DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Wyświetlaj tu wiadomości i inne powiadomienia podczas gry. Pozostają tylko na tym ekranie i nie są zapisywane. Android zapyta raz: na tym ekranie otworzy się jego strona dostępu do powiadomień; włącz tam DroidDeck, a ta strona wróci sama.</string>
|
||||
<string name="bottom_notifications_allow">Zezwól na dostęp do powiadomień</string>
|
||||
<string name="bottom_notifications_none">Brak powiadomień.</string>
|
||||
<string name="bottom_notifications_count">Powiadomienia: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Wyczyść</string>
|
||||
<string name="bottom_page_screenshots">Zrzuty ekranu</string>
|
||||
<string name="stats_fps_needs_hud">FPS wymaga włączonego HUD</string>
|
||||
<string name="stats_speed">Prędkość</string>
|
||||
@@ -2926,6 +2918,8 @@
|
||||
<string name="display_position_top">Góra</string>
|
||||
<string name="display_position_bottom">Dół</string>
|
||||
<string name="display_position_center">Środek (domyślnie)</string>
|
||||
<string name="display_position_left">Lewo</string>
|
||||
<string name="display_position_right">Prawo</string>
|
||||
<string name="setup_tab_storage">Pamięć</string>
|
||||
<string name="storage_measuring">Mierzenie pamięci…</string>
|
||||
<string name="storage_measuring_hint">Pierwszy raz trwa kilka sekund.</string>
|
||||
@@ -3739,4 +3733,7 @@
|
||||
<string name="input_reset_positions">Resetuj położenie</string>
|
||||
<string name="input_show_label">Pokaż etykietę</string>
|
||||
<string name="input_layout_import_failed">Nie można zaimportować tego układu</string>
|
||||
|
||||
<string name="theme_white_red">Szkarłatny śnieg</string>
|
||||
|
||||
</resources>
|
||||
@@ -1230,7 +1230,6 @@
|
||||
<string name="deck_tab_gamepad">Comando</string>
|
||||
<string name="deck_surface_stick">Joystick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Alternar entre joystick e trackpad</string>
|
||||
<string name="deck_close">Fechar controlos do segundo ecrã</string>
|
||||
<string name="deck_open_steam_menu">Abrir menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad direito</string>
|
||||
@@ -1794,13 +1793,6 @@
|
||||
<string name="bottom_downloads_shader">A transferir a cache de shaders</string>
|
||||
<string name="bottom_downloads_paused">Em pausa</string>
|
||||
<string name="bottom_downloads_queued">A aguardar início</string>
|
||||
<string name="bottom_page_notifications">Notificações</string>
|
||||
<string name="bottom_notifications_listener">Ecrã inferior do DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto joga. Ficam apenas neste ecrã e não são guardadas. O Android pergunta uma vez: a página de acesso às notificações abre neste ecrã; ative lá o DroidDeck e esta página volta automaticamente.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acesso às notificações</string>
|
||||
<string name="bottom_notifications_none">Sem notificações.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificações</string>
|
||||
<string name="bottom_notifications_clear">Limpar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de ecrã</string>
|
||||
<string name="stats_fps_needs_hud">O FPS requer o HUD ativado</string>
|
||||
<string name="stats_speed">Velocidade</string>
|
||||
@@ -2895,6 +2887,8 @@
|
||||
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
|
||||
<string name="display_picture_position">Posição da imagem</string>
|
||||
<string name="display_position_top">Topo</string>
|
||||
<string name="display_position_left">Esquerda</string>
|
||||
<string name="display_position_right">Direita</string>
|
||||
<string name="display_position_bottom">Fundo</string>
|
||||
<string name="display_position_center">Centro (predefinido)</string>
|
||||
<string name="setup_tab_storage">Armazenamento</string>
|
||||
@@ -3700,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Repor posições</string>
|
||||
<string name="input_show_label">Mostrar etiqueta</string>
|
||||
<string name="input_layout_import_failed">Não foi possível importar este esquema</string>
|
||||
|
||||
<string name="theme_white_red">Neve Escarlate</string>
|
||||
|
||||
</resources>
|
||||
@@ -1230,7 +1230,6 @@
|
||||
<string name="deck_tab_gamepad">Controle</string>
|
||||
<string name="deck_surface_stick">Analógico</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Alternar entre analógico e trackpad</string>
|
||||
<string name="deck_close">Fechar controles da segunda tela</string>
|
||||
<string name="deck_open_steam_menu">Abrir o menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad direito</string>
|
||||
@@ -1794,13 +1793,6 @@
|
||||
<string name="bottom_downloads_shader">Baixando o cache de shaders</string>
|
||||
<string name="bottom_downloads_paused">Pausado</string>
|
||||
<string name="bottom_downloads_queued">Aguardando para começar</string>
|
||||
<string name="bottom_page_notifications">Notificações</string>
|
||||
<string name="bottom_notifications_listener">Tela inferior do DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto você joga. Elas ficam só nesta tela e não são salvas. O Android pergunta uma vez: a página de acesso a notificações dele abre nesta tela; ative o DroidDeck lá e esta página volta sozinha.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acesso a notificações</string>
|
||||
<string name="bottom_notifications_none">Nenhuma notificação.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificações</string>
|
||||
<string name="bottom_notifications_clear">Limpar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de tela</string>
|
||||
<string name="stats_fps_needs_hud">O FPS precisa do HUD ativado</string>
|
||||
<string name="stats_speed">Velocidade</string>
|
||||
@@ -2895,6 +2887,8 @@
|
||||
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
|
||||
<string name="display_picture_position">Posição da imagem</string>
|
||||
<string name="display_position_top">Topo</string>
|
||||
<string name="display_position_left">Esquerda</string>
|
||||
<string name="display_position_right">Direita</string>
|
||||
<string name="display_position_bottom">Base</string>
|
||||
<string name="display_position_center">Centro (padrão)</string>
|
||||
<string name="setup_tab_storage">Armazenamento</string>
|
||||
@@ -3700,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Redefinir posições</string>
|
||||
<string name="input_show_label">Mostrar rótulo</string>
|
||||
<string name="input_layout_import_failed">Não foi possível importar este layout</string>
|
||||
|
||||
<string name="theme_white_red">Neve Escarlate</string>
|
||||
|
||||
</resources>
|
||||
@@ -1238,7 +1238,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Manșă</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Comută între manșă și trackpad</string>
|
||||
<string name="deck_close">Închide comenzile celui de-al doilea ecran</string>
|
||||
<string name="deck_open_steam_menu">Deschide meniul Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad-ul drept</string>
|
||||
@@ -1802,13 +1801,6 @@
|
||||
<string name="bottom_downloads_shader">Se descarcă cache-ul de shadere</string>
|
||||
<string name="bottom_downloads_paused">Întrerupt</string>
|
||||
<string name="bottom_downloads_queued">Așteaptă să înceapă</string>
|
||||
<string name="bottom_page_notifications">Notificări</string>
|
||||
<string name="bottom_notifications_listener">Ecranul de jos DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Afișează aici mesajele și alte notificări în timp ce te joci. Rămân doar pe acest ecran și nu sunt salvate. Android întreabă o singură dată: pagina sa de acces la notificări se deschide pe acest ecran; activează DroidDeck acolo, iar această pagină revine singură.</string>
|
||||
<string name="bottom_notifications_allow">Permite accesul la notificări</string>
|
||||
<string name="bottom_notifications_none">Nicio notificare.</string>
|
||||
<string name="bottom_notifications_count">Notificări: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Șterge</string>
|
||||
<string name="bottom_page_screenshots">Capturi de ecran</string>
|
||||
<string name="stats_fps_needs_hud">FPS necesită HUD-ul activat</string>
|
||||
<string name="stats_speed">Viteză</string>
|
||||
@@ -2897,6 +2889,8 @@
|
||||
<string name="display_position_top">Sus</string>
|
||||
<string name="display_position_bottom">Jos</string>
|
||||
<string name="display_position_center">Centru (implicit)</string>
|
||||
<string name="display_position_left">Stânga</string>
|
||||
<string name="display_position_right">Dreapta</string>
|
||||
<string name="setup_tab_storage">Stocare</string>
|
||||
<string name="storage_measuring">Se măsoară stocarea…</string>
|
||||
<string name="storage_measuring_hint">Prima dată durează câteva secunde.</string>
|
||||
@@ -3700,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Resetează pozițiile</string>
|
||||
<string name="input_show_label">Afișează eticheta</string>
|
||||
<string name="input_layout_import_failed">Acest aranjament nu a putut fi importat</string>
|
||||
|
||||
<string name="theme_white_red">Zăpadă stacojie</string>
|
||||
|
||||
</resources>
|
||||
@@ -1282,7 +1282,6 @@
|
||||
<string name="deck_tab_gamepad">Геймпад</string>
|
||||
<string name="deck_surface_stick">Стик</string>
|
||||
<string name="deck_surface_trackpad">Тачпад</string>
|
||||
<string name="deck_surface_switch">Переключение между стиком и тачпадом</string>
|
||||
<string name="deck_close">Закрыть управление вторым экраном</string>
|
||||
<string name="deck_open_steam_menu">Открыть меню Steam</string>
|
||||
<string name="deck_right_trackpad">Правый тачпад</string>
|
||||
@@ -1889,13 +1888,6 @@
|
||||
<string name="bottom_downloads_shader">Загрузка кэша шейдеров</string>
|
||||
<string name="bottom_downloads_paused">Приостановлено</string>
|
||||
<string name="bottom_downloads_queued">Ожидает начала</string>
|
||||
<string name="bottom_page_notifications">Уведомления</string>
|
||||
<string name="bottom_notifications_listener">Нижний экран DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Показывает сообщения и другие уведомления здесь, пока вы играете. Они отображаются только на этом экране и не сохраняются. Android спросит один раз: на этом экране откроется страница доступа к уведомлениям; включите там DroidDeck, и эта страница вернется сама.</string>
|
||||
<string name="bottom_notifications_allow">Разрешить доступ к уведомлениям</string>
|
||||
<string name="bottom_notifications_none">Нет уведомлений.</string>
|
||||
<string name="bottom_notifications_count">Уведомлений: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Очистить</string>
|
||||
<string name="bottom_page_screenshots">Скриншоты</string>
|
||||
<string name="stats_fps_needs_hud">Для FPS нужно включить HUD</string>
|
||||
<string name="stats_speed">Скорость</string>
|
||||
@@ -3009,6 +3001,8 @@
|
||||
<string name="display_position_top">Сверху</string>
|
||||
<string name="display_position_bottom">Снизу</string>
|
||||
<string name="display_position_center">По центру (по умолчанию)</string>
|
||||
<string name="display_position_left">Слева</string>
|
||||
<string name="display_position_right">Справа</string>
|
||||
<string name="setup_tab_storage">Хранилище</string>
|
||||
<string name="storage_measuring">Измерение хранилища…</string>
|
||||
<string name="storage_measuring_hint">В первый раз занимает несколько секунд.</string>
|
||||
@@ -3822,4 +3816,7 @@
|
||||
<string name="input_reset_positions">Сбросить положение</string>
|
||||
<string name="input_show_label">Показывать подпись</string>
|
||||
<string name="input_layout_import_failed">Не удалось импортировать эту раскладку</string>
|
||||
|
||||
<string name="theme_white_red">Алый снег</string>
|
||||
|
||||
</resources>
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Handkontroll</string>
|
||||
<string name="deck_surface_stick">Styrspak</string>
|
||||
<string name="deck_surface_trackpad">Styrplatta</string>
|
||||
<string name="deck_surface_switch">Växla mellan styrspak och styrplatta</string>
|
||||
<string name="deck_close">Stäng kontrollerna på andra skärmen</string>
|
||||
<string name="deck_open_steam_menu">Öppna Steam-menyn</string>
|
||||
<string name="deck_right_trackpad">Höger styrplatta</string>
|
||||
@@ -1778,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Laddar ned shadercache</string>
|
||||
<string name="bottom_downloads_paused">Pausad</string>
|
||||
<string name="bottom_downloads_queued">Väntar på att starta</string>
|
||||
<string name="bottom_page_notifications">Aviseringar</string>
|
||||
<string name="bottom_notifications_listener">DroidDecks nedre skärm</string>
|
||||
<string name="bottom_notifications_why">Visa meddelanden och andra aviseringar här medan du spelar. De visas bara på den här skärmen och sparas inte. Android frågar en gång: sidan för aviseringsåtkomst öppnas på den här skärmen; slå på DroidDeck där så kommer den här sidan tillbaka av sig själv.</string>
|
||||
<string name="bottom_notifications_allow">Tillåt aviseringsåtkomst</string>
|
||||
<string name="bottom_notifications_none">Inga aviseringar.</string>
|
||||
<string name="bottom_notifications_count">%1$d aviseringar</string>
|
||||
<string name="bottom_notifications_clear">Rensa</string>
|
||||
<string name="bottom_page_screenshots">Skärmbilder</string>
|
||||
<string name="stats_fps_needs_hud">FPS kräver att HUD:en är på</string>
|
||||
<string name="stats_speed">Hastighet</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Överst</string>
|
||||
<string name="display_position_bottom">Nederst</string>
|
||||
<string name="display_position_center">Mitten (standard)</string>
|
||||
<string name="display_position_left">Vänster</string>
|
||||
<string name="display_position_right">Höger</string>
|
||||
<string name="setup_tab_storage">Lagring</string>
|
||||
<string name="storage_measuring">Mäter lagring…</string>
|
||||
<string name="storage_measuring_hint">Första gången tar några sekunder.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Återställ positioner</string>
|
||||
<string name="input_show_label">Visa etikett</string>
|
||||
<string name="input_layout_import_failed">Det gick inte att importera layouten</string>
|
||||
|
||||
<string name="theme_white_red">Scharlakansröd snö</string>
|
||||
|
||||
</resources>
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">เกมแพด</string>
|
||||
<string name="deck_surface_stick">สติ๊ก</string>
|
||||
<string name="deck_surface_trackpad">แทร็กแพด</string>
|
||||
<string name="deck_surface_switch">สลับระหว่างสติ๊กและแทร็กแพด</string>
|
||||
<string name="deck_close">ปิดการควบคุมหน้าจอที่สอง</string>
|
||||
<string name="deck_open_steam_menu">เปิดเมนู Steam</string>
|
||||
<string name="deck_right_trackpad">แทร็กแพดขวา</string>
|
||||
@@ -1754,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">กำลังดาวน์โหลดแคชเชเดอร์</string>
|
||||
<string name="bottom_downloads_paused">หยุดชั่วคราว</string>
|
||||
<string name="bottom_downloads_queued">กำลังรอเริ่ม</string>
|
||||
<string name="bottom_page_notifications">การแจ้งเตือน</string>
|
||||
<string name="bottom_notifications_listener">หน้าจอล่างของ DroidDeck</string>
|
||||
<string name="bottom_notifications_why">แสดงข้อความและการแจ้งเตือนอื่นๆ ที่นี่ขณะที่คุณเล่น การแจ้งเตือนจะแสดงบนหน้าจอนี้เท่านั้นและจะไม่ถูกบันทึก Android จะถามเพียงครั้งเดียว: หน้าการเข้าถึงการแจ้งเตือนจะเปิดขึ้นบนหน้าจอนี้ เปิด DroidDeck ที่นั่น แล้วหน้านี้จะกลับมาเอง</string>
|
||||
<string name="bottom_notifications_allow">อนุญาตการเข้าถึงการแจ้งเตือน</string>
|
||||
<string name="bottom_notifications_none">ไม่มีการแจ้งเตือน</string>
|
||||
<string name="bottom_notifications_count">การแจ้งเตือน %1$d รายการ</string>
|
||||
<string name="bottom_notifications_clear">ล้าง</string>
|
||||
<string name="bottom_page_screenshots">ภาพหน้าจอ</string>
|
||||
<string name="stats_fps_needs_hud">ต้องเปิด HUD เพื่อแสดง FPS</string>
|
||||
<string name="stats_speed">ความเร็ว</string>
|
||||
@@ -2839,6 +2831,8 @@
|
||||
<string name="display_position_top">บน</string>
|
||||
<string name="display_position_bottom">ล่าง</string>
|
||||
<string name="display_position_center">กลาง (ค่าเริ่มต้น)</string>
|
||||
<string name="display_position_left">ซ้าย</string>
|
||||
<string name="display_position_right">ขวา</string>
|
||||
<string name="setup_tab_storage">พื้นที่จัดเก็บ</string>
|
||||
<string name="storage_measuring">กำลังวัดพื้นที่…</string>
|
||||
<string name="storage_measuring_hint">ครั้งแรกใช้เวลาสองสามวินาที</string>
|
||||
@@ -3631,4 +3625,7 @@
|
||||
<string name="input_reset_positions">รีเซ็ตตำแหน่ง</string>
|
||||
<string name="input_show_label">แสดงป้ายกำกับ</string>
|
||||
<string name="input_layout_import_failed">ไม่สามารถนำเข้าการจัดวางนี้ได้</string>
|
||||
|
||||
<string name="theme_white_red">หิมะสีแดงชาด</string>
|
||||
|
||||
</resources>
|
||||
@@ -1223,7 +1223,6 @@
|
||||
<string name="deck_tab_gamepad">Oyun kolu</string>
|
||||
<string name="deck_surface_stick">Çubuk</string>
|
||||
<string name="deck_surface_trackpad">Dokunmatik yüzey</string>
|
||||
<string name="deck_surface_switch">Çubuk ile dokunmatik yüzey arasında geçiş yap</string>
|
||||
<string name="deck_close">İkinci ekran kontrollerini kapat</string>
|
||||
<string name="deck_open_steam_menu">Steam menüsünü aç</string>
|
||||
<string name="deck_right_trackpad">Sağ dokunmatik yüzey</string>
|
||||
@@ -1770,13 +1769,6 @@
|
||||
<string name="bottom_downloads_shader">Gölgelendirici önbelleği indiriliyor</string>
|
||||
<string name="bottom_downloads_paused">Duraklatıldı</string>
|
||||
<string name="bottom_downloads_queued">Başlamayı bekliyor</string>
|
||||
<string name="bottom_page_notifications">Bildirimler</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck alt ekranı</string>
|
||||
<string name="bottom_notifications_why">Oynarken mesajları ve diğer bildirimleri burada görün. Yalnızca bu ekranda kalırlar ve kaydedilmezler. Android bunu bir kez sorar: bildirim erişimi sayfası bu ekranda açılır; orada DroidDeck\'i açın, bu sayfa kendiliğinden geri gelir.</string>
|
||||
<string name="bottom_notifications_allow">Bildirim erişimine izin ver</string>
|
||||
<string name="bottom_notifications_none">Bildirim yok.</string>
|
||||
<string name="bottom_notifications_count">%1$d bildirim</string>
|
||||
<string name="bottom_notifications_clear">Temizle</string>
|
||||
<string name="bottom_page_screenshots">Ekran görüntüleri</string>
|
||||
<string name="stats_fps_needs_hud">FPS için HUD açık olmalı</string>
|
||||
<string name="stats_speed">Hız</string>
|
||||
@@ -2868,6 +2860,8 @@
|
||||
<string name="display_position_top">Üst</string>
|
||||
<string name="display_position_bottom">Alt</string>
|
||||
<string name="display_position_center">Orta (varsayılan)</string>
|
||||
<string name="display_position_left">Sol</string>
|
||||
<string name="display_position_right">Sağ</string>
|
||||
<string name="setup_tab_storage">Depolama</string>
|
||||
<string name="storage_measuring">Depolama ölçülüyor…</string>
|
||||
<string name="storage_measuring_hint">İlk seferde birkaç saniye sürer.</string>
|
||||
@@ -3661,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Konumları sıfırla</string>
|
||||
<string name="input_show_label">Etiketi göster</string>
|
||||
<string name="input_layout_import_failed">Bu düzen içe aktarılamadı</string>
|
||||
|
||||
<string name="theme_white_red">Kızıl Kar</string>
|
||||
|
||||
</resources>
|
||||
@@ -1245,7 +1245,6 @@
|
||||
<string name="deck_tab_gamepad">Геймпад</string>
|
||||
<string name="deck_surface_stick">Стік</string>
|
||||
<string name="deck_surface_trackpad">Трекпад</string>
|
||||
<string name="deck_surface_switch">Перемкнути між стіком і трекпадом</string>
|
||||
<string name="deck_close">Закрити керування на другому екрані</string>
|
||||
<string name="deck_open_steam_menu">Відкрити меню Steam</string>
|
||||
<string name="deck_right_trackpad">Правий трекпад</string>
|
||||
@@ -1826,13 +1825,6 @@
|
||||
<string name="bottom_downloads_shader">Завантаження кешу шейдерів</string>
|
||||
<string name="bottom_downloads_paused">Призупинено</string>
|
||||
<string name="bottom_downloads_queued">Очікує початку</string>
|
||||
<string name="bottom_page_notifications">Сповіщення</string>
|
||||
<string name="bottom_notifications_listener">Нижній екран DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Показуйте тут повідомлення та інші сповіщення під час гри. Вони лишаються лише на цьому екрані й не зберігаються. Android запитає один раз: сторінка доступу до сповіщень відкриється на цьому екрані; увімкніть там DroidDeck, і ця сторінка повернеться сама.</string>
|
||||
<string name="bottom_notifications_allow">Дозволити доступ до сповіщень</string>
|
||||
<string name="bottom_notifications_none">Немає сповіщень.</string>
|
||||
<string name="bottom_notifications_count">Сповіщень: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Очистити</string>
|
||||
<string name="bottom_page_screenshots">Знімки екрана</string>
|
||||
<string name="stats_fps_needs_hud">Для FPS потрібно ввімкнути HUD</string>
|
||||
<string name="stats_speed">Швидкість</string>
|
||||
@@ -2926,6 +2918,8 @@
|
||||
<string name="display_position_top">Угорі</string>
|
||||
<string name="display_position_bottom">Унизу</string>
|
||||
<string name="display_position_center">По центру (типово)</string>
|
||||
<string name="display_position_left">Ліворуч</string>
|
||||
<string name="display_position_right">Праворуч</string>
|
||||
<string name="setup_tab_storage">Сховище</string>
|
||||
<string name="storage_measuring">Вимірювання сховища…</string>
|
||||
<string name="storage_measuring_hint">Уперше триває кілька секунд.</string>
|
||||
@@ -3739,4 +3733,7 @@
|
||||
<string name="input_reset_positions">Скинути розташування</string>
|
||||
<string name="input_show_label">Показувати підпис</string>
|
||||
<string name="input_layout_import_failed">Не вдалося імпортувати цю розкладку</string>
|
||||
|
||||
<string name="theme_white_red">Багряний сніг</string>
|
||||
|
||||
</resources>
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">Tay cầm</string>
|
||||
<string name="deck_surface_stick">Cần</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Chuyển giữa cần và trackpad</string>
|
||||
<string name="deck_close">Đóng điều khiển màn hình phụ</string>
|
||||
<string name="deck_open_steam_menu">Mở menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad phải</string>
|
||||
@@ -1754,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">Đang tải bộ nhớ đệm shader</string>
|
||||
<string name="bottom_downloads_paused">Đã tạm dừng</string>
|
||||
<string name="bottom_downloads_queued">Đang chờ bắt đầu</string>
|
||||
<string name="bottom_page_notifications">Thông báo</string>
|
||||
<string name="bottom_notifications_listener">Màn hình dưới DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Hiển thị tin nhắn và các thông báo khác ở đây khi bạn chơi. Chúng chỉ ở trên màn hình này và không được lưu. Android chỉ hỏi một lần: trang quyền truy cập thông báo sẽ mở trên màn hình này; hãy bật DroidDeck ở đó và trang này sẽ tự quay lại.</string>
|
||||
<string name="bottom_notifications_allow">Cho phép truy cập thông báo</string>
|
||||
<string name="bottom_notifications_none">Không có thông báo.</string>
|
||||
<string name="bottom_notifications_count">%1$d thông báo</string>
|
||||
<string name="bottom_notifications_clear">Xóa</string>
|
||||
<string name="bottom_page_screenshots">Ảnh chụp màn hình</string>
|
||||
<string name="stats_fps_needs_hud">Cần bật HUD để xem FPS</string>
|
||||
<string name="stats_speed">Tốc độ</string>
|
||||
@@ -2839,6 +2831,8 @@
|
||||
<string name="display_position_top">Trên</string>
|
||||
<string name="display_position_bottom">Dưới</string>
|
||||
<string name="display_position_center">Giữa (mặc định)</string>
|
||||
<string name="display_position_left">Trái</string>
|
||||
<string name="display_position_right">Phải</string>
|
||||
<string name="setup_tab_storage">Lưu trữ</string>
|
||||
<string name="storage_measuring">Đang đo dung lượng…</string>
|
||||
<string name="storage_measuring_hint">Lần đầu mất vài giây.</string>
|
||||
@@ -3631,4 +3625,7 @@
|
||||
<string name="input_reset_positions">Đặt lại vị trí</string>
|
||||
<string name="input_show_label">Hiện nhãn</string>
|
||||
<string name="input_layout_import_failed">Không thể nhập bố cục này</string>
|
||||
|
||||
<string name="theme_white_red">Tuyết đỏ thẫm</string>
|
||||
|
||||
</resources>
|
||||
@@ -1308,7 +1308,6 @@
|
||||
<string name="deck_tab_gamepad">手柄</string>
|
||||
<string name="deck_surface_stick">摇杆</string>
|
||||
<string name="deck_surface_trackpad">触控板</string>
|
||||
<string name="deck_surface_switch">在摇杆与触控板之间切换</string>
|
||||
<string name="deck_close">关闭副屏控制</string>
|
||||
<string name="deck_open_steam_menu">打开 Steam 菜单</string>
|
||||
<string name="deck_right_trackpad">右触控板</string>
|
||||
@@ -1832,13 +1831,6 @@
|
||||
<string name="bottom_downloads_shader">正在下载着色器缓存</string>
|
||||
<string name="bottom_downloads_paused">已暂停</string>
|
||||
<string name="bottom_downloads_queued">等待开始</string>
|
||||
<string name="bottom_page_notifications">通知</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck 底部屏幕</string>
|
||||
<string name="bottom_notifications_why">在游戏时于此处显示消息和其他通知。通知只显示在此屏幕上,不会被保存。Android 只会询问一次:其通知使用权页面会在此屏幕上打开,在那里开启 DroidDeck 后,会自动返回此页面。</string>
|
||||
<string name="bottom_notifications_allow">允许通知使用权</string>
|
||||
<string name="bottom_notifications_none">没有通知。</string>
|
||||
<string name="bottom_notifications_count">%1$d 条通知</string>
|
||||
<string name="bottom_notifications_clear">清除</string>
|
||||
<string name="bottom_page_screenshots">截图</string>
|
||||
<string name="stats_fps_needs_hud">显示 FPS 需要开启 HUD</string>
|
||||
<string name="stats_speed">速度</string>
|
||||
@@ -2843,6 +2835,8 @@
|
||||
<string name="display_position_top">置顶</string>
|
||||
<string name="display_position_bottom">置底</string>
|
||||
<string name="display_position_center">居中(默认)</string>
|
||||
<string name="display_position_left">左侧</string>
|
||||
<string name="display_position_right">右侧</string>
|
||||
<string name="setup_tab_storage">存储</string>
|
||||
<string name="storage_measuring">正在统计存储…</string>
|
||||
<string name="storage_measuring_hint">首次统计需要几秒钟。</string>
|
||||
@@ -3635,4 +3629,7 @@
|
||||
<string name="input_reset_positions">重置位置</string>
|
||||
<string name="input_show_label">显示标签</string>
|
||||
<string name="input_layout_import_failed">无法导入此布局</string>
|
||||
|
||||
<string name="theme_white_red">猩红之雪</string>
|
||||
|
||||
</resources>
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">控制器</string>
|
||||
<string name="deck_surface_stick">搖桿</string>
|
||||
<string name="deck_surface_trackpad">觸控板</string>
|
||||
<string name="deck_surface_switch">在搖桿和觸控板之間切換</string>
|
||||
<string name="deck_close">關閉第二螢幕控制</string>
|
||||
<string name="deck_open_steam_menu">開啟 Steam 選單</string>
|
||||
<string name="deck_right_trackpad">右側觸控板</string>
|
||||
@@ -1754,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">正在下載著色器快取</string>
|
||||
<string name="bottom_downloads_paused">已暫停</string>
|
||||
<string name="bottom_downloads_queued">等待開始</string>
|
||||
<string name="bottom_page_notifications">通知</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck 下方螢幕</string>
|
||||
<string name="bottom_notifications_why">在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。</string>
|
||||
<string name="bottom_notifications_allow">允許通知存取權</string>
|
||||
<string name="bottom_notifications_none">沒有通知。</string>
|
||||
<string name="bottom_notifications_count">%1$d 則通知</string>
|
||||
<string name="bottom_notifications_clear">清除</string>
|
||||
<string name="bottom_page_screenshots">螢幕截圖</string>
|
||||
<string name="stats_fps_needs_hud">顯示 FPS 需要開啟 HUD</string>
|
||||
<string name="stats_speed">速度</string>
|
||||
@@ -2839,6 +2831,8 @@
|
||||
<string name="display_position_top">置頂</string>
|
||||
<string name="display_position_bottom">置底</string>
|
||||
<string name="display_position_center">置中(預設)</string>
|
||||
<string name="display_position_left">左側</string>
|
||||
<string name="display_position_right">右側</string>
|
||||
<string name="setup_tab_storage">儲存空間</string>
|
||||
<string name="storage_measuring">正在統計儲存空間…</string>
|
||||
<string name="storage_measuring_hint">首次統計需要幾秒鐘。</string>
|
||||
@@ -3634,4 +3628,7 @@
|
||||
<string name="input_reset_positions">重設位置</string>
|
||||
<string name="input_show_label">顯示標籤</string>
|
||||
<string name="input_layout_import_failed">無法匯入此佈局</string>
|
||||
|
||||
<string name="theme_white_red">緋紅之雪</string>
|
||||
|
||||
</resources>
|
||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user