diff --git a/EMULATOR_CREDITS.md b/EMULATOR_CREDITS.md index 253fdd917..b8a2463ae 100644 --- a/EMULATOR_CREDITS.md +++ b/EMULATOR_CREDITS.md @@ -92,6 +92,18 @@ the user's own Lossless Scaling installation, which they must own separately on | Snapdragon Game Super Resolution (SGSR) | Upscaling shader | BSD-3-Clause | https://github.com/quic/snapdragon-gsr | | Winlator | Windows-on-Android base this project forks | GPL-3.0 | https://github.com/brunodev85/winlator | +## Controller support + +Steam Controller support and the visual controller test / binding screen were written by +**The412Banner** for **Bannerlator** and ported into WinNative. The SDL3 Android release AAR is +vendored unmodified under `vendor/maven/org/libsdl/android/SDL3/` and is only used to read Valve +HIDAPI devices; every other controller keeps WinNative's normal Android input path. + +| Component | Role | License | Source | +| --- | --- | --- | --- | +| Bannerlator | Steam Controller backend, pad artwork and the controller test / visual binder this port is derived from | GPL-3.0 | https://github.com/The412Banner/Bannerlator | +| SDL3 3.4.16 (official Android release AAR, unmodified) | HIDAPI Steam Controller drivers | Zlib | https://github.com/libsdl-org/SDL | + ## Source availability WinNative is released under the GPL-3.0. As required by that license and by the GPL-2.0 diff --git a/README.md b/README.md index 490a403be..0097e0899 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ Please match the existing code style and ensure any AI-assisted code is thorough - **DIS optical flow** — the algorithm and its reference implementation come from [OpenCV](https://github.com/opencv/opencv) (`DISOpticalFlow`, [LICENSE](https://github.com/opencv/opencv/blob/5.x/LICENSE)), which adopted Till Kroeger's original [OF_DIS](https://github.com/tikroeger/OF_DIS) - **DXVK** by [Philip Rebohle and contributors](https://github.com/doitsujin/dxvk) (zlib/libpng) — the `dxbc` shader translator, vendored at `app/src/main/cpp/thirdparty/dxbc` to convert the frame generation shaders to SPIR-V - **DirectAudio** by [The412Banner](https://github.com/The412Banner/directaudio) (LGPL-2.1-or-later) — the native Wine → Android AAudio audio driver, and the only audio path in WinNative that carries a working microphone. See [DirectAudio — what came from The412Banner's driver](#directaudio--what-came-from-the412banners-driver) below +- **Steam Controller support and the visual controller test / binder** by [The412Banner](https://github.com/The412Banner/Bannerlator) (GPL-3.0) — written for **Bannerlator** and ported into WinNative, including the per-family pad artwork. See [Steam Controller — what came from The412Banner's Bannerlator](#steam-controller--what-came-from-the412banners-bannerlator) below +- **SDL3** by [Sam Lantinga and the SDL contributors](https://github.com/libsdl-org/SDL) (Zlib) — its HIDAPI Steam drivers are what actually read the Steam Controller; the official 3.4.16 Android release AAR is vendored unmodified at `vendor/maven/org/libsdl/android/SDL3/` - **Lossless Scaling** (Steam) — the source of the frame generation shaders. They are read from the user's own installed copy at runtime; none are redistributed with WinNative #### Frame generation — what came from Camille LaVey's Eden port @@ -238,6 +240,66 @@ The engine sits behind its own settings — a Fast / Balance / Quality flow reso optional target frame rate — kept separate from the Lossless Scaling ones, and the two engines are mutually exclusive because the compositor drives one interpolator per frame. +#### Steam Controller — what came from The412Banner's Bannerlator + +WinNative's Steam Controller support, the animated controller test panel and the tap-the-pad +visual binder are **The412Banner's work**, written for +**[Bannerlator](https://github.com/The412Banner/Bannerlator)** and ported here. The design, the +SDL3 bridge, the input model, the pad artwork and the hotspot manifest are all his; WinNative's +own part is the adaptation to this fork's input plumbing (`WinHandler`'s slot machinery, the +`InputControlsView` binding lane and the Compose settings screen) plus full localization. + +Out of the box a Steam Controller is not a gamepad: it presents as a keyboard and mouse +("lizard mode"), so Android never reports a controller and WinNative's normal `InputDevice` path +cannot see it. SDL3's HIDAPI Steam drivers speak Valve's protocol instead — over Bluetooth LE or +USB — keep lizard mode off and parse the reports. Only those drivers are enabled +(`SDL_HINT_JOYSTICK_HIDAPI=0`, `SDL_HINT_JOYSTICK_HIDAPI_STEAM=1`), so SDL never opens, claims or +requests USB permission for any other controller, and every non-Valve pad keeps the untouched +Android input path. + +| Piece | What it does | +| --- | --- | +| `steamctrl/steam_controller_bridge.cpp` | JNI bridge over SDL3's gamepad API: flat int/float arrays for up to four Valve HIDAPI pads, plus rumble | +| `SteamControllerBackend` | ~250 Hz poll thread, frame coalescing onto the main thread, the 22-bit button model, trackpad-as-mouse and the back-paddle / "…" bindings | +| `WinHandler` SDL pads | Seats each pad in the ordinary player-slot machinery under a synthetic device id, shadows the Valve device Android also exposes, and routes rumble back through SDL | +| `ControllerTestPanel` | The animated pad picture: live button glow, movable stick nubs that glow with deflection, trigger and battery readouts, and the inputs-verified tally (18 on a Steam Controller) | +| `VisualControllerBinder` | Tap a button on the pad art to rebind it, written into the same profile store the list editor uses | +| `pad_*.png` | The per-family controller artwork (Xbox 360 / Xbox / DualSense / DualShock 4 / DualShock 3 / Switch Pro / 8BitDo / GameCube / SNES / Steam / Generic) | + +Support is **off by default** and lives in *Settings → Input Controls → Steam Controller*. While +it is off, SDL is never loaded and the normal controller path is byte-for-byte unchanged. + +Credit history — every commit below is The412Banner's, in +[The412Banner/Bannerlator](https://github.com/The412Banner/Bannerlator): + +| Commit | Date | Subject | +| --- | --- | --- | +| `9ca9bcf` | 2026-08-31 | Controller Test: reuse the visualizer in Settings > Input Controls | +| `d32a03f` | 2026-08-31 | Controller Test: accurate per-family pad art + smaller in-game popup | +| `1678f23` | 2026-08-31 | Controller Test: bound the pad picture to a compact size | +| `4344ef4` | 2026-08-31 | Visual controller binder: tap the pad to remap, in Settings + in-game | +| `475721e` | 2026-08-31 | Controller pads: pre-rendered PNG art + overlay; Settings test-first; fix live-input arm | +| `8d2e1e3` | 2026-08-31 | Controller test: movable stick nubs + pinned footer (Identify never clips) | +| `90a8286` | 2026-08-31 | Controller test: stick nubs glow on deflection + landscape pad-left / metrics-right | +| `1e0d46f` | 2026-08-31 | Controller test: landscape metrics as a 2x2 grid so all four tiles fit | +| `9722956` | 2026-08-31 | feat(controller-test): independent OSC/physical profile lanes + live profile lists | +| `095269d` | 2026-08-31 | Bind screen: add Delete profile + outline/dividers on menus and popup | +| `b02d981` | 2026-08-31 | Controller test: unify popup-menu styling on outlinedMenuCard; rename Settings card | +| `b9e6552` | 2026-09-10 | Steam Controller support via SDL3 (opt-in, Input Controls > Device) | +| `e5b1272` | 2026-09-10 | Steam Controller: fetch pad name/path on the poll thread | +| `6d3b2a7` | 2026-09-10 | Steam Controller: back buttons, profile bindings, settings test dialog | +| `d03f6b1` | 2026-09-10 | Steam Controller: make the "…" (Quick Access) button mappable | +| `3d243e4` | 2026-09-10 | Controller test: count the Steam Controller's "…" button (18 inputs) | +| `b597711` | 2026-09-10 | Steam Controller: pick which trackpad moves the mouse (right / left / both / off) | +| `c2acbbd` | 2026-09-10 | Merge branch 'feat/steam-controller-sdl': opt-in Steam Controller support via SDL3 | + +Bannerlator is GPL-3.0, the same license as WinNative, and remains so. SDL3 is Zlib-licensed and +is vendored unmodified; see [`vendor/maven/README.md`](vendor/maven/README.md) and +[`EMULATOR_CREDITS.md`](EMULATOR_CREDITS.md). + +> Steam Controller support, controller test and visual binder by The412Banner +> (https://github.com/The412Banner/Bannerlator) + #### DirectAudio — what came from The412Banner's driver WinNative's microphone support exists because **The412Banner** wrote diff --git a/app/build.gradle b/app/build.gradle index 068effc67..7c00647a4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -402,6 +402,7 @@ dependencies { implementation 'com.android.ndk.thirdparty:openssl:1.1.1q-beta-1' implementation 'com.android.ndk.thirdparty:curl:7.85.0-beta-1' + implementation 'org.libsdl.android:SDL3:3.4.16@aar' implementation libs.playServicesGamesV2 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 309cd5ea7..6b7645bc0 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -50,6 +50,9 @@ native ; } +# SDL3 (Steam Controller): SDL's native side resolves these classes by name. +-keep class org.libsdl.app.** { *; } + # zstd-jni — the native side does GetFieldID("srcPos", "J") / # GetFieldID("dstPos", "J") on ZstdInputStreamNoFinalizer and # similar via JNI; R8 renames the Kotlin/Java fields and the lookup diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt index 8310ea467..04b63dcfd 100644 --- a/app/src/main/app/PluviaApp.kt +++ b/app/src/main/app/PluviaApp.kt @@ -42,8 +42,15 @@ class PluviaApp : Application() { super.onCreate() instance = this + val systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable) + if (systemUncaughtHandler != null) { + systemUncaughtHandler.uncaughtException(thread, throwable) + } else { + android.os.Process.killProcess(android.os.Process.myPid()) + kotlin.system.exitProcess(10) + } } com.winlator.cmod.feature.retro.Ps2GameOverlay.install() diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index f40134d88..0ca8de85f 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -227,3 +227,11 @@ add_library(waudio SHARED ) target_compile_options(waudio PRIVATE -Wall -Wextra -fvisibility=hidden) target_link_libraries(waudio log dl) + +find_package(SDL3 REQUIRED CONFIG) + +add_library(steamctrl SHARED + steamctrl/steam_controller_bridge.cpp +) +target_compile_options(steamctrl PRIVATE -Wall -Wextra -fvisibility=hidden) +target_link_libraries(steamctrl SDL3::SDL3 log) diff --git a/app/src/main/cpp/steamctrl/steam_controller_bridge.cpp b/app/src/main/cpp/steamctrl/steam_controller_bridge.cpp new file mode 100644 index 000000000..48891e96d --- /dev/null +++ b/app/src/main/cpp/steamctrl/steam_controller_bridge.cpp @@ -0,0 +1,237 @@ +#include +#include +#include + +#define SDL_MAIN_HANDLED +#include +#include + +#define TAG "SteamCtrlBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) + +namespace { + +constexpr int kMaxPads = 4; +constexpr Uint16 kValveVendorId = 0x28DE; + +constexpr int I_ID = 0; +constexpr int I_BUTTONS = 1; +constexpr int I_STRIDE = 2; + +constexpr int F_LX = 0, F_LY = 1, F_RX = 2, F_RY = 3; +constexpr int F_LT = 4, F_RT = 5; +constexpr int F_RPAD_DOWN = 6, F_RPAD_X = 7, F_RPAD_Y = 8; +constexpr int F_LPAD_DOWN = 9, F_LPAD_X = 10, F_LPAD_Y = 11; +constexpr int F_STRIDE = 12; + +enum : int { + B_A = 0, B_B, B_X, B_Y, B_LB, B_RB, B_BACK, B_START, B_LSTICK, B_RSTICK, B_GUIDE, + B_DPAD_UP, B_DPAD_DOWN, B_DPAD_LEFT, B_DPAD_RIGHT, + B_QAM, B_R4, B_L4, B_R5, B_L5, B_RPAD_CLICK, B_LPAD_CLICK, +}; + +struct ButtonMap { SDL_GamepadButton sdl; int bit; }; +constexpr ButtonMap kButtons[] = { + { SDL_GAMEPAD_BUTTON_SOUTH, B_A }, + { SDL_GAMEPAD_BUTTON_EAST, B_B }, + { SDL_GAMEPAD_BUTTON_WEST, B_X }, + { SDL_GAMEPAD_BUTTON_NORTH, B_Y }, + { SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, B_LB }, + { SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, B_RB }, + { SDL_GAMEPAD_BUTTON_BACK, B_BACK }, + { SDL_GAMEPAD_BUTTON_START, B_START }, + { SDL_GAMEPAD_BUTTON_LEFT_STICK, B_LSTICK }, + { SDL_GAMEPAD_BUTTON_RIGHT_STICK, B_RSTICK }, + { SDL_GAMEPAD_BUTTON_GUIDE, B_GUIDE }, + { SDL_GAMEPAD_BUTTON_DPAD_UP, B_DPAD_UP }, + { SDL_GAMEPAD_BUTTON_DPAD_DOWN, B_DPAD_DOWN }, + { SDL_GAMEPAD_BUTTON_DPAD_LEFT, B_DPAD_LEFT }, + { SDL_GAMEPAD_BUTTON_DPAD_RIGHT, B_DPAD_RIGHT }, + { SDL_GAMEPAD_BUTTON_MISC1, B_QAM }, + { SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, B_R4 }, + { SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, B_L4 }, + { SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, B_R5 }, + { SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, B_L5 }, + { SDL_GAMEPAD_BUTTON_MISC2, B_RPAD_CLICK }, + { SDL_GAMEPAD_BUTTON_TOUCHPAD, B_LPAD_CLICK }, +}; + +struct Pad { + SDL_JoystickID id; + SDL_Gamepad *gamepad; +}; + +std::mutex g_lock; +Pad g_pads[kMaxPads]; +int g_numPads = 0; +bool g_initialized = false; + +bool isValveHidapiPad(SDL_JoystickID id) { + if (SDL_GetGamepadVendorForID(id) != kValveVendorId) return false; + return SDL_GetGamepadGUIDForID(id).data[14] == 'h'; +} +float stickAxis(SDL_Gamepad *gamepad, SDL_GamepadAxis axis) { + float v = SDL_GetGamepadAxis(gamepad, axis) / 32767.0f; + return v < -1.0f ? -1.0f : (v > 1.0f ? 1.0f : v); +} +float triggerAxis(SDL_Gamepad *gamepad, SDL_GamepadAxis axis) { + float v = SDL_GetGamepadAxis(gamepad, axis) / 32767.0f; + return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v); +} +void readTouchpad(SDL_Gamepad *gamepad, int touchpad, float *out) { + bool down = false; + float x = 0.0f, y = 0.0f, pressure = 0.0f; + if (touchpad < SDL_GetNumGamepadTouchpads(gamepad) + && SDL_GetGamepadTouchpadFinger(gamepad, touchpad, 0, &down, &x, &y, &pressure)) { + out[0] = down ? 1.0f : 0.0f; + out[1] = x; + out[2] = y; + } else { + out[0] = out[1] = out[2] = 0.0f; + } +} +void syncPadsLocked() { + int count = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&count); + + for (int i = 0; i < g_numPads;) { + bool present = false; + for (int j = 0; j < count; j++) { + if (ids[j] == g_pads[i].id) { present = true; break; } + } + if (present && SDL_GamepadConnected(g_pads[i].gamepad)) { + i++; + continue; + } + LOGI("Steam controller %d disconnected", (int)g_pads[i].id); + SDL_CloseGamepad(g_pads[i].gamepad); + g_pads[i] = g_pads[--g_numPads]; + } + + for (int j = 0; j < count && g_numPads < kMaxPads; j++) { + SDL_JoystickID id = ids[j]; + bool known = false; + for (int i = 0; i < g_numPads; i++) { + if (g_pads[i].id == id) { known = true; break; } + } + if (known || !isValveHidapiPad(id)) continue; + SDL_Gamepad *gamepad = SDL_OpenGamepad(id); + if (!gamepad) { + LOGW("SDL_OpenGamepad(%d) failed: %s", (int)id, SDL_GetError()); + continue; + } + g_pads[g_numPads++] = { id, gamepad }; + LOGI("Steam controller %d connected: %s (pid %04x, touchpads %d, path %s)", (int)id, + SDL_GetGamepadNameForID(id), SDL_GetGamepadProductForID(id), + SDL_GetNumGamepadTouchpads(gamepad), SDL_GetGamepadPathForID(id)); + } + + SDL_free(ids); +} +Pad *findPadLocked(SDL_JoystickID id) { + for (int i = 0; i < g_numPads; i++) { + if (g_pads[i].id == id) return &g_pads[i]; + } + return nullptr; +} +} +extern "C" { + +JNIEXPORT jboolean JNICALL +Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeInit(JNIEnv *, jclass, jboolean bluetooth) { + std::lock_guard guard(g_lock); + if (g_initialized) return JNI_TRUE; + + SDL_SetMainReady(); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "0"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, bluetooth ? "1" : "0"); + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + SDL_SetHint(SDL_HINT_TV_REMOTE_AS_JOYSTICK, "0"); + + if (!SDL_Init(SDL_INIT_GAMEPAD)) { + LOGW("SDL_Init(GAMEPAD) failed: %s", SDL_GetError()); + return JNI_FALSE; + } + if (!bluetooth) SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1"); + + SDL_SetJoystickEventsEnabled(false); + SDL_SetGamepadEventsEnabled(false); + + g_initialized = true; + int v = SDL_GetVersion(); + LOGI("SDL %d.%d.%d up: HIDAPI Steam drivers only (bluetooth %s)", SDL_VERSIONNUM_MAJOR(v), + SDL_VERSIONNUM_MINOR(v), SDL_VERSIONNUM_MICRO(v), bluetooth ? "on" : "off"); + return JNI_TRUE; +} +JNIEXPORT jint JNICALL +Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativePoll(JNIEnv *env, jclass, jintArray ints, jfloatArray floats) { + std::lock_guard guard(g_lock); + if (!g_initialized) return -1; + + SDL_UpdateGamepads(); + SDL_FlushEvents(SDL_EVENT_FIRST, SDL_EVENT_LAST); + syncPadsLocked(); + + jint iv[kMaxPads * I_STRIDE] = {}; + jfloat fv[kMaxPads * F_STRIDE] = {}; + for (int p = 0; p < g_numPads; p++) { + SDL_Gamepad *gamepad = g_pads[p].gamepad; + jint *pi = iv + p * I_STRIDE; + jfloat *pf = fv + p * F_STRIDE; + + int buttons = 0; + for (const ButtonMap &m : kButtons) { + if (SDL_GetGamepadButton(gamepad, m.sdl)) buttons |= 1 << m.bit; + } + pi[I_ID] = (jint)g_pads[p].id; + pi[I_BUTTONS] = buttons; + + pf[F_LX] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX); + pf[F_LY] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY); + pf[F_RX] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX); + pf[F_RY] = stickAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY); + pf[F_LT] = triggerAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER); + pf[F_RT] = triggerAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + readTouchpad(gamepad, 1, pf + F_RPAD_DOWN); + readTouchpad(gamepad, 0, pf + F_LPAD_DOWN); + } + + if (g_numPads > 0) { + env->SetIntArrayRegion(ints, 0, g_numPads * I_STRIDE, iv); + env->SetFloatArrayRegion(floats, 0, g_numPads * F_STRIDE, fv); + } + return g_numPads; +} +JNIEXPORT jstring JNICALL +Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeGetName(JNIEnv *env, jclass, jint id) { + std::lock_guard guard(g_lock); + const char *name = g_initialized ? SDL_GetGamepadNameForID((SDL_JoystickID)id) : nullptr; + return env->NewStringUTF(name ? name : "Steam Controller"); +} +JNIEXPORT jstring JNICALL +Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeGetPath(JNIEnv *env, jclass, jint id) { + std::lock_guard guard(g_lock); + const char *path = g_initialized ? SDL_GetGamepadPathForID((SDL_JoystickID)id) : nullptr; + return path ? env->NewStringUTF(path) : nullptr; +} +JNIEXPORT void JNICALL +Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeRumble(JNIEnv *, jclass, jint id, jint low, jint high, jint durationMs) { + std::lock_guard guard(g_lock); + if (!g_initialized) return; + Pad *pad = findPadLocked((SDL_JoystickID)id); + if (!pad) return; + auto clamp16 = [](jint v) -> Uint16 { return (Uint16)(v < 0 ? 0 : (v > 0xFFFF ? 0xFFFF : v)); }; + SDL_RumbleGamepad(pad->gamepad, clamp16(low), clamp16(high), (Uint32)(durationMs < 0 ? 0 : durationMs)); +} +JNIEXPORT void JNICALL +Java_com_winlator_cmod_runtime_input_controls_SteamControllerBackend_nativeShutdown(JNIEnv *, jclass) { + std::lock_guard guard(g_lock); + if (!g_initialized) return; + for (int i = 0; i < g_numPads; i++) SDL_CloseGamepad(g_pads[i].gamepad); + g_numPads = 0; + SDL_Quit(); + g_initialized = false; + LOGI("SDL shut down"); +} +} \ No newline at end of file diff --git a/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt b/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt index afc0e61f9..6a5ffcf08 100644 --- a/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt +++ b/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt @@ -102,6 +102,7 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( private val dialog: Dialog private val nav = GameSettingsNav() private var restorePaneNav: (() -> Unit)? = null + private var activityObserver: DefaultLifecycleObserver? = null private val state = GameSettingsStateHolder() private val manager = ContainerManager(context) private val contentsManager = ContentsManager(context) @@ -147,6 +148,8 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( // path as Save/Cancel and still fires onFinished (important for // the setup wizard launcher that blocks on UnifiedActivity finishing). setOnDismissListener { + activityObserver?.let { (activity as LifecycleOwner).lifecycle.removeObserver(it) } + activityObserver = null restorePaneNav?.invoke() restorePaneNav = null AppUtils.hideKeyboard(activity) @@ -192,11 +195,14 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( } dialog.setContentView(composeView) - (activity as LifecycleOwner).lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - if (dialog.isShowing) dialog.dismiss() + val observer = + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + if (dialog.isShowing) dialog.dismiss() + } } - }) + activityObserver = observer + (activity as LifecycleOwner).lifecycle.addObserver(observer) loadContentsAsync() } @@ -689,23 +695,24 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( } private fun loadContentsAsync() { - Executors.newSingleThreadExecutor().execute { + val executor = Executors.newSingleThreadExecutor() + executor.execute { try { contentsManager.syncContents() - activity.runOnUiThread { - try { - populateContentsDependentData() - } finally { - state.isLoaded.value = true - } - } - } catch (e: Exception) { - Log.e(TAG, "Error syncing contents", e) - activity.runOnUiThread { + } catch (t: Throwable) { + Log.e(TAG, "Error syncing contents", t) + } + activity.runOnUiThread { + try { + if (dialog.isShowing) populateContentsDependentData() + } catch (t: Throwable) { + Log.e(TAG, "Error populating container settings", t) + } finally { state.isLoaded.value = true } } } + executor.shutdown() } private fun populateContentsDependentData() { diff --git a/app/src/main/feature/settings/input/InputControlsFragment.kt b/app/src/main/feature/settings/input/InputControlsFragment.kt index 154f293f4..8c04fc848 100644 --- a/app/src/main/feature/settings/input/InputControlsFragment.kt +++ b/app/src/main/feature/settings/input/InputControlsFragment.kt @@ -47,6 +47,10 @@ import com.winlator.cmod.shared.io.HttpUtils import com.winlator.cmod.shared.math.Mathf import com.winlator.cmod.shared.ui.dialog.ContentDialog import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.runtime.input.controls.SteamControllerBackend +import com.winlator.cmod.runtime.input.controls.SteamControllerPrefs +import com.winlator.cmod.shared.ui.controllertest.ControllerTestBus +import com.winlator.cmod.shared.ui.controllertest.ControllerTestDialog import org.json.JSONObject import java.io.File import java.util.concurrent.atomic.AtomicBoolean @@ -79,6 +83,10 @@ class InputControlsFragment : Fragment() { private var activeBindingL2WasPressed = false private var activeBindingR2WasPressed = false + private var showControllerTest by mutableStateOf(false) + private val testController = ExternalController() + private var testGuideDown = false + private var gyroSensorManager: SensorManager? = null private var gyroListener: SensorEventListener? = null private var gyroPreviewView: InputControlsView? = null @@ -221,8 +229,29 @@ class InputControlsFragment : Fragment() { onBindingTypeClick = ::showBindingTypePicker, onBindingValueClick = ::showBindingValuePicker, onRemoveBinding = ::removeBinding, + onOpenControllerTest = { + ControllerTestBus.onIdentify = Runnable { identifyController() } + showControllerTest = true + }, + onSteamControllerEnabledChanged = ::setSteamControllerEnabled, + onSteamTrackpadModeSelected = ::setSteamTrackpadMode, + onSteamPaddleClick = ::pickSteamPaddleBinding, ), ) + if (showControllerTest) { + ControllerTestDialog( + onDismiss = { showControllerTest = false }, + profile = currentProfile, + allProfiles = manager.profiles.toList(), + onSelectProfile = { selectProfile(it) }, + onCreateProfile = { createProfileNamed(it) }, + onRenameProfile = ::editProfile, + onBindingsChanged = { + refreshVisibleControllers() + publishUiState() + }, + ) + } } } } @@ -244,10 +273,26 @@ class InputControlsFragment : Fragment() { override fun onDestroyView() { detachGyroPreview() stopControllerInputCapture() + ControllerTestBus.setDialogOpen(false) + ControllerTestBus.onIdentify = null super.onDestroyView() } fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (showControllerTest && ControllerTestBus.isActive()) { + val device = event.device + if (ExternalController.isGameController(device)) { + if (event.repeatCount == 0) { + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE) { + testGuideDown = event.action == KeyEvent.ACTION_DOWN + } + prepareTestController(device) + testController.updateStateFromKeyEvent(event) + ControllerTestBus.publish(testController, device, testGuideDown) + } + return true + } + } val controller = activeBindingController ?: return false if (event.deviceId != controller.deviceId) return false if (event.repeatCount != 0) return true @@ -258,6 +303,16 @@ class InputControlsFragment : Fragment() { } fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (showControllerTest && ControllerTestBus.isActive()) { + val device = event.device + if (ExternalController.isGameController(device)) { + prepareTestController(device) + if (testController.updateStateFromMotionEvent(event)) { + ControllerTestBus.publish(testController, device, testGuideDown) + } + return true + } + } val controller = activeBindingController ?: return false if (event.deviceId != controller.deviceId) return false if (!controller.updateStateFromMotionEvent(event)) return false @@ -305,6 +360,7 @@ class InputControlsFragment : Fragment() { } private fun publishUiState() { + val ctx = context ?: return val profile = currentProfile val bindingTypeEntries = resources.getStringArray(R.array.binding_type_entries) val triggerDescription = @@ -342,6 +398,12 @@ class InputControlsFragment : Fragment() { triggerTypeIndex = preferences.getInt("trigger_type", ExternalController.TRIGGER_IS_AXIS.toInt()), triggerCardExpanded = triggerTypeExpanded, triggerDescription = triggerDescription, + steamControllerEnabled = SteamControllerPrefs.isEnabled(ctx), + steamTrackpadModeIndex = SteamControllerPrefs.getTrackpadMouseMode(ctx), + steamPaddleLabels = + (0 until SteamControllerBackend.PADDLE_COUNT).map { index -> + SteamControllerPrefs.getPaddleBinding(ctx, index).toString() + }, controllerCards = visibleControllers.map { controller -> InputControllerCardState( @@ -468,6 +530,75 @@ class InputControlsFragment : Fragment() { } } + private fun selectProfile(profile: ControlsProfile) { + currentProfile = profile + persistSelectedProfileId() + expandedControllerIds.clear() + stopControllerInputCapture() + refreshVisibleControllers() + publishUiState() + } + + private fun createProfileNamed(name: String) { + currentProfile = manager.createProfile(name) + persistSelectedProfileId() + refreshVisibleControllers() + publishUiState() + } + + private fun identifyController() { + val deviceId = testController.deviceId + val vibrator = android.view.InputDevice.getDevice(deviceId)?.vibrator ?: return + if (!vibrator.hasVibrator()) return + vibrator.vibrate( + android.os.VibrationEffect.createOneShot(320L, android.os.VibrationEffect.DEFAULT_AMPLITUDE), + ) + } + + private fun prepareTestController(device: android.view.InputDevice?) { + if (device == null) return + if (testController.deviceId != device.id) { + testController.setDeviceId(device.id) + testController.id = device.descriptor + testController.name = device.name + testController.triggerType = ExternalController.TRIGGER_IS_BOTH + testGuideDown = false + } + } + + private fun setSteamControllerEnabled(enabled: Boolean) { + SteamControllerPrefs.setEnabled(requireContext(), enabled) + publishUiState() + } + + private fun setSteamTrackpadMode(mode: Int) { + SteamControllerPrefs.setTrackpadMouseMode(requireContext(), mode) + publishUiState() + } + + private fun pickSteamPaddleBinding(index: Int) { + val values = steamPaddleBindingValues() + val labels = values.map { it.toString() }.toTypedArray() + val current = SteamControllerPrefs.getPaddleBinding(requireContext(), index) + val checked = values.indexOf(current).let { if (it >= 0) it else 0 } + showChoiceDialog( + title = getString(R.string.steam_controller_extra_buttons), + items = labels, + checkedIndex = checked, + ) { which -> + SteamControllerPrefs.setPaddleBinding(requireContext(), index, values[which]) + publishUiState() + } + } + + private fun steamPaddleBindingValues(): List = + buildList { + add(Binding.NONE) + addAll(Binding.gamepadBindingValues().filterNot { it.name.contains("_THUMB_") }) + addAll(Binding.keyboardBindingValues()) + addAll(Binding.mouseBindingValues()) + }.distinct() + private fun openControlsEditor() { val profile = currentProfile if (profile != null) { diff --git a/app/src/main/feature/settings/input/InputControlsScreen.kt b/app/src/main/feature/settings/input/InputControlsScreen.kt index a25818a64..be90724b0 100644 --- a/app/src/main/feature/settings/input/InputControlsScreen.kt +++ b/app/src/main/feature/settings/input/InputControlsScreen.kt @@ -183,6 +183,9 @@ data class InputControlsScreenState( val triggerCardExpanded: Boolean = false, val triggerDescription: String = "", val controllerCards: List = emptyList(), + val steamControllerEnabled: Boolean = false, + val steamTrackpadModeIndex: Int = 1, + val steamPaddleLabels: List = emptyList(), val dialog: InputControlsDialogUiState = InputControlsDialogUiState.None, ) @@ -289,6 +292,10 @@ data class InputControlsScreenActions( val onBindingTypeClick: (String, Int) -> Unit, val onBindingValueClick: (String, Int) -> Unit, val onRemoveBinding: (String, Int) -> Unit, + val onOpenControllerTest: () -> Unit, + val onSteamControllerEnabledChanged: (Boolean) -> Unit, + val onSteamTrackpadModeSelected: (Int) -> Unit, + val onSteamPaddleClick: (Int) -> Unit, ) @Composable @@ -365,6 +372,14 @@ fun InputControlsScreen( title = stringResource(R.string.gesture_profile_export), onClick = actions.onExportGestureProfile, ) + SectionLabel(stringResource(R.string.controller_test_title)) + ActionCard( + icon = Icons.Outlined.SportsEsports, + title = stringResource(R.string.controller_test_card_title), + onClick = actions.onOpenControllerTest, + ) + SectionLabel(stringResource(R.string.steam_controller_section)) + SteamControllerCard(state, actions) SectionLabel(stringResource(R.string.session_gamepad_external_controllers)) if (state.controllerCards.isEmpty()) { EmptyStateCard(stringResource(R.string.common_ui_no_items_to_display)) @@ -2596,6 +2611,122 @@ private fun GyroscopeCard( } } +@Composable +private fun SteamControllerCard( + state: InputControlsScreenState, + actions: InputControlsScreenActions, +) { + CardShell { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .paneNavItem( + cornerRadius = InputCardCorner, + onActivate = { + actions.onSteamControllerEnabledChanged(!state.steamControllerEnabled) + }, + highlightColor = InputNavHighlight, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + IconBox( + image = Icons.Outlined.SportsEsports, + tint = if (state.steamControllerEnabled) InputAccent else InputTextSecondary, + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.steam_controller_enable), + color = InputTextPrimary, + fontSize = InputPrimaryTextSize, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(1.dp)) + Text( + text = stringResource(R.string.steam_controller_enable_summary), + color = InputTextSecondary, + fontSize = InputSecondaryTextSize, + ) + } + Spacer(Modifier.width(8.dp)) + AppSwitch( + checked = state.steamControllerEnabled, + onCheckedChange = actions.onSteamControllerEnabledChanged, + ) + } + AnimatedVisibility( + visible = state.steamControllerEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(InputItemGap)) + Text( + text = stringResource(R.string.steam_controller_trackpad_mode), + color = InputTextSecondary, + fontSize = InputSectionTextSize, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(InputCompactGap)) + ChipRow( + options = + listOf( + stringResource(R.string.steam_controller_trackpad_off), + stringResource(R.string.steam_controller_trackpad_right), + stringResource(R.string.steam_controller_trackpad_left), + stringResource(R.string.steam_controller_trackpad_both), + ), + selectedIndex = state.steamTrackpadModeIndex, + onSelected = actions.onSteamTrackpadModeSelected, + ) + Spacer(Modifier.height(InputItemGap)) + Text( + text = stringResource(R.string.steam_controller_extra_buttons), + color = InputTextSecondary, + fontSize = InputSectionTextSize, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(InputCompactGap)) + val paddleTitles = + listOf( + stringResource(R.string.steam_controller_paddle_l4), + stringResource(R.string.steam_controller_paddle_l5), + stringResource(R.string.steam_controller_paddle_r4), + stringResource(R.string.steam_controller_paddle_r5), + stringResource(R.string.steam_controller_button_qam), + ) + paddleTitles.forEachIndexed { index, title -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + color = InputTextPrimary, + fontSize = InputSecondaryTextSize, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + SelectionPill( + text = state.steamPaddleLabels.getOrNull(index) ?: "", + onClick = { actions.onSteamPaddleClick(index) }, + ) + } + } + Spacer(Modifier.height(InputCompactGap)) + Text( + text = stringResource(R.string.steam_controller_restart_required), + color = InputTextSecondary, + fontSize = InputSecondaryTextSize, + ) + } + } + } + } +} + @Composable private fun TriggerTypeCard( state: InputControlsScreenState, diff --git a/app/src/main/res/drawable-nodpi/pad_dualsense.png b/app/src/main/res/drawable-nodpi/pad_dualsense.png new file mode 100644 index 000000000..568152c79 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_dualsense.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_dualshock3.png b/app/src/main/res/drawable-nodpi/pad_dualshock3.png new file mode 100644 index 000000000..dadd9c756 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_dualshock3.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_dualshock4.png b/app/src/main/res/drawable-nodpi/pad_dualshock4.png new file mode 100644 index 000000000..941ca51e7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_dualshock4.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_eightbitdo.png b/app/src/main/res/drawable-nodpi/pad_eightbitdo.png new file mode 100644 index 000000000..f83f84777 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_eightbitdo.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_gamecube.png b/app/src/main/res/drawable-nodpi/pad_gamecube.png new file mode 100644 index 000000000..54a548098 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_gamecube.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_generic.png b/app/src/main/res/drawable-nodpi/pad_generic.png new file mode 100644 index 000000000..f8b2b41fb Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_generic.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_snes.png b/app/src/main/res/drawable-nodpi/pad_snes.png new file mode 100644 index 000000000..ff8878d59 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_snes.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_steam.png b/app/src/main/res/drawable-nodpi/pad_steam.png new file mode 100644 index 000000000..8e2da5c50 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_steam.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_switch_pro.png b/app/src/main/res/drawable-nodpi/pad_switch_pro.png new file mode 100644 index 000000000..27c45dfc8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_switch_pro.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_xbox360.png b/app/src/main/res/drawable-nodpi/pad_xbox360.png new file mode 100644 index 000000000..96ab5110c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_xbox360.png differ diff --git a/app/src/main/res/drawable-nodpi/pad_xbox_modern.png b/app/src/main/res/drawable-nodpi/pad_xbox_modern.png new file mode 100644 index 000000000..e5e1fe13b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pad_xbox_modern.png differ diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b1faa0ce4..114935ca4 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2629,4 +2629,88 @@ Ruta instalada: Control Actualizado Actualización disponible — %1$s + Prueba del control + Prueba y asignación del control + Revisa cada entrada en la imagen del control y reasigna los botones tocándolos + Probar + Asignar + Entrada en vivo + Presiona un botón en el control… + Batería %1$d%% + Última entrada + Palanca izquierda + Palanca derecha + Gatillos L·R + Todas las entradas responden (%1$d probadas) + %1$d / %2$d entradas verificadas + Identificar + Auto + Detección automática + Ver / Compartir + Menú / Opciones + Clic de la palanca izquierda + Clic de la palanca derecha + Cruceta arriba + Cruceta abajo + Cruceta izquierda + Cruceta derecha + Guía + … (Acceso rápido) + Perfil + — ninguno — + — Ninguno (Xbox nativo) — + Renombrar… + Eliminar… + + Nuevo + Nombre del nuevo perfil + Crear + Todas las asignaciones + Selecciona o crea un perfil para asignar este control. + Conecta un control para asignarlo. Las asignaciones se guardan por control. + La reasignación está activada — %1$d de %2$d asignados + Los botones sin asignar quedan mudos en el juego: dejan de actuar como un control Xbox nativo. + Rellenar → nativo + Asignaciones · %1$d de %2$d asignados + nativo + Asignar: + Actualmente: %1$s + nativo (paso directo) + Teclado + Mouse + Xbox + Ninguno + Nativo (%1$s) + Borrar + L3 (clic de la palanca) + R3 (clic de la palanca) + Start + Back + Guía + Cruceta arriba + Cruceta abajo + Cruceta izquierda + Cruceta derecha + Palanca izquierda arriba + Palanca izquierda abajo + Palanca izquierda a la izquierda + Palanca izquierda a la derecha + Palanca derecha arriba + Palanca derecha abajo + Palanca derecha a la izquierda + Palanca derecha a la derecha + Steam Controller + Compatibilidad con Steam Controller + Lee un Steam Controller emparejado por Bluetooth LE o USB. Desactivado de forma predeterminada; mientras esté desactivado, la ruta normal de controles no se toca. + Mouse del panel táctil + Desactivado + Panel derecho + Panel izquierdo + Ambos paneles + Botones adicionales + L4 (superior izquierda) + L5 (inferior izquierda) + R4 (superior derecha) + R5 (inferior derecha) + … (Acceso rápido) + Se aplica la próxima vez que inicies un juego. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 079f1e275..c83319a7d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2631,4 +2631,89 @@ Installeret sti: Gamepad Opdateret Opdatering tilgængelig — %1$s + + Controller-test + Controller-test & binding + Kontrollér hvert input på controllerbilledet, og tildel knapper igen ved at trykke på dem + Test + Bind + Live-input + Tryk på en knap på controlleren… + Batteri %1$d%% + Seneste input + Venstre stick + Højre stick + Triggere L·R + Alle input registreres (%1$d testet) + %1$d / %2$d input verificeret + Identificer + Auto + Registrer automatisk + Vis / Del + Menu / Indstillinger + Venstre stick-klik + Højre stick-klik + D-pad op + D-pad ned + D-pad venstre + D-pad højre + Guide + … (Hurtig adgang) + Profil + — ingen — + — Ingen (indbygget Xbox) — + Omdøb… + Slet… + + Ny + Navn på ny profil + Opret + Alle bindinger + Vælg eller opret en profil for at binde denne controller. + Tilslut en controller for at binde den. Bindinger gemmes pr. controller. + Omdefinering er slået til — %1$d af %2$d tildelt + Knapper uden binding er tavse i spillet: de fungerer ikke længere som en indbygget Xbox-controller. + Udfyld → indbygget + Bindinger · %1$d af %2$d tildelt + indbygget + Bind: + Aktuelt: %1$s + indbygget (direkte) + Tastatur + Mus + Xbox + Ingen + Indbygget (%1$s) + Ryd + L3 (stick-klik) + R3 (stick-klik) + Start + Tilbage + Guide + D-pad op + D-pad ned + D-pad venstre + D-pad højre + Venstre stick op + Venstre stick ned + Venstre stick venstre + Venstre stick højre + Højre stick op + Højre stick ned + Højre stick venstre + Højre stick højre + Steam Controller + Understøttelse af Steam Controller + Læs en parret Steam Controller via Bluetooth LE eller USB. Slået fra som standard; den normale controller-sti berøres ikke, mens den er slået fra. + Trackpad-mus + Fra + Højre trackpad + Venstre trackpad + Begge trackpads + Ekstra knapper + L4 (øverst til venstre) + L5 (nederst til venstre) + R4 (øverst til højre) + R5 (nederst til højre) + … (Hurtig adgang) + Træder i kraft, næste gang et spil starter. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9371fd055..01f7667e3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2631,4 +2631,88 @@ Installierter Pfad: Gamepad Aktuell Update verfügbar — %1$s + Controller-Test + Controller-Test & Belegung + Prüfe jede Eingabe am Controller-Bild und belege Tasten neu, indem du sie antippst + Test + Belegen + Live-Eingabe + Drücke eine Taste auf dem Controller… + Akku %1$d%% + Letzte Eingabe + Linker Stick + Rechter Stick + Trigger L·R + Alle Eingaben werden erkannt (%1$d getestet) + %1$d / %2$d Eingaben geprüft + Identifizieren + Auto + Automatisch erkennen + Ansicht / Teilen + Menü / Optionen + Linker Stick-Klick + Rechter Stick-Klick + Steuerkreuz Oben + Steuerkreuz Unten + Steuerkreuz Links + Steuerkreuz Rechts + Guide + … (Schnellzugriff) + Profil + — keins — + — Keins (natives Xbox) — + Umbenennen… + Löschen… + + Neu + Name des neuen Profils + Erstellen + Alle Belegungen + Wähle oder erstelle ein Profil, um diesen Controller zu belegen. + Verbinde einen Controller, um ihn zu belegen. Belegungen werden pro Controller gespeichert. + Neubelegung ist aktiv — %1$d von %2$d belegt + Nicht belegte Tasten bleiben im Spiel wirkungslos: Sie verhalten sich nicht mehr wie ein natives Xbox-Gamepad. + Füllen → nativ + Belegungen · %1$d von %2$d belegt + nativ + Belegen: + Aktuell: %1$s + nativ (Durchleitung) + Tastatur + Maus + Xbox + Keine + Nativ (%1$s) + Löschen + L3 (Stick-Klick) + R3 (Stick-Klick) + Start + Zurück + Guide + Steuerkreuz Oben + Steuerkreuz Unten + Steuerkreuz Links + Steuerkreuz Rechts + Linker Stick Oben + Linker Stick Unten + Linker Stick Links + Linker Stick Rechts + Rechter Stick Oben + Rechter Stick Unten + Rechter Stick Links + Rechter Stick Rechts + Steam Controller + Steam Controller-Unterstützung + Einen gekoppelten Steam Controller über Bluetooth LE oder USB auslesen. Standardmäßig aus; solange es aus ist, bleibt der normale Controller-Pfad unberührt. + Trackpad-Maus + Aus + Rechtes Pad + Linkes Pad + Beide Pads + Zusatztasten + L4 (oben links) + L5 (unten links) + R4 (oben rechts) + R5 (unten rechts) + … (Schnellzugriff) + Wird beim nächsten Spielstart wirksam. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1149adaa3..19113bc85 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2630,5 +2630,89 @@ Ruta instalada: Mando Actualizado Actualización disponible — %1$s + Prueba del mando + Prueba y asignación del mando + Comprueba cada entrada en la imagen del mando y reasigna los botones tocándolos + Probar + Asignar + Entrada en directo + Pulsa un botón en el mando… + Batería %1$d%% + Última entrada + Joystick izquierdo + Joystick derecho + Gatillos L·R + Todas las entradas responden (%1$d probadas) + %1$d / %2$d entradas verificadas + Identificar + Auto + Detección automática + Ver / Compartir + Menú / Opciones + Clic del joystick izquierdo + Clic del joystick derecho + D-pad arriba + D-pad abajo + D-pad izquierda + D-pad derecha + Guía + … (Acceso rápido) + Perfil + — ninguno — + — Ninguno (Xbox nativo) — + Renombrar… + Eliminar… + + Nuevo + Nombre del nuevo perfil + Crear + Todas las asignaciones + Selecciona o crea un perfil para asignar este mando. + Conecta un mando para asignarlo. Las asignaciones se guardan por mando. + La reasignación está activada — %1$d de %2$d asignados + Los botones sin asignar quedan mudos en el juego: dejan de actuar como un mando Xbox nativo. + Rellenar → nativo + Asignaciones · %1$d de %2$d asignados + nativo + Asignar: + Actualmente: %1$s + nativo (paso directo) + Teclado + Ratón + Xbox + Ninguno + Nativo (%1$s) + Borrar + L3 (clic del joystick) + R3 (clic del joystick) + Start + Back + Guía + D-pad arriba + D-pad abajo + D-pad izquierda + D-pad derecha + Joystick izquierdo arriba + Joystick izquierdo abajo + Joystick izquierdo izquierda + Joystick izquierdo derecha + Joystick derecho arriba + Joystick derecho abajo + Joystick derecho izquierda + Joystick derecho derecha + Steam Controller + Compatibilidad con Steam Controller + Lee un Steam Controller emparejado por Bluetooth LE o USB. Desactivado de forma predeterminada; mientras esté desactivado, la ruta normal de mandos no se toca. + Ratón del panel táctil + Desactivado + Panel derecho + Panel izquierdo + Ambos paneles + Botones adicionales + L4 (superior izquierda) + L5 (inferior izquierda) + R4 (superior derecha) + R5 (inferior derecha) + … (Acceso rápido) + Se aplica la próxima vez que inicies un juego. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 823005f6f..1c4b6b99f 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2629,4 +2629,89 @@ Asennuspolku: Peliohjain Ajan tasalla Päivitys saatavilla — %1$s + + Ohjaintesti + Ohjaintesti ja sidonta + Tarkista jokainen syöte ohjaimen kuvasta ja määritä painikkeet uudelleen napauttamalla niitä + Testi + Sido + Reaaliaikainen syöte + Paina painiketta ohjaimessa… + Akku %1$d%% + Viimeisin syöte + Vasen sauva + Oikea sauva + Liipaisimet L·R + Kaikki syötteet rekisteröityvät (%1$d testattu) + %1$d / %2$d syötettä varmennettu + Tunnista + Auto + Tunnista automaattisesti + Näytä / Jaa + Valikko / Asetukset + Vasemman sauvan painallus + Oikean sauvan painallus + Ristiohjain ylös + Ristiohjain alas + Ristiohjain vasemmalle + Ristiohjain oikealle + Guide + … (Pikavalikko) + Profiili + — ei mitään — + — Ei mitään (natiivi Xbox) — + Nimeä uudelleen… + Poista… + + Uusi + Uuden profiilin nimi + Luo + Kaikki sidonnat + Valitse tai luo profiili, jotta voit sitoa tämän ohjaimen. + Liitä ohjain, jotta voit sitoa sen. Sidonnat tallennetaan ohjainkohtaisesti. + Uudelleenmääritys on päällä — %1$d / %2$d määritetty + Sitomattomat painikkeet vaikenevat pelissä: ne eivät enää toimi natiivina Xbox-ohjaimena. + Täytä → natiivi + Sidonnat · %1$d / %2$d määritetty + natiivi + Sido: + Tällä hetkellä: %1$s + natiivi (läpivienti) + Näppäimistö + Hiiri + Xbox + Ei mitään + Natiivi (%1$s) + Tyhjennä + L3 (sauvan painallus) + R3 (sauvan painallus) + Start + Takaisin + Guide + Ristiohjain ylös + Ristiohjain alas + Ristiohjain vasemmalle + Ristiohjain oikealle + Vasen sauva ylös + Vasen sauva alas + Vasen sauva vasemmalle + Vasen sauva oikealle + Oikea sauva ylös + Oikea sauva alas + Oikea sauva vasemmalle + Oikea sauva oikealle + Steam Controller + Steam Controller -tuki + Lue paritettua Steam Controller -ohjainta Bluetooth LE- tai USB-yhteydellä. Oletuksena pois päältä; tavallinen ohjainpolku pysyy koskemattomana, kun tämä on pois päältä. + Kosketuslevyhiiri + Pois + Oikea kosketuslevy + Vasen kosketuslevy + Molemmat kosketuslevyt + Lisäpainikkeet + L4 (ylhäällä vasemmalla) + L5 (alhaalla vasemmalla) + R4 (ylhäällä oikealla) + R5 (alhaalla oikealla) + … (Pikavalikko) + Tulee voimaan, kun peli käynnistetään seuraavan kerran. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 29edb4508..7d1095345 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2630,5 +2630,89 @@ Chemin installé : Manette À jour Mise à jour disponible — %1$s + Test de la manette + Test et affectation de la manette + Vérifiez chaque entrée sur l\'image de la manette et réaffectez les boutons en les touchant + Test + Affecter + Entrée en direct + Appuyez sur un bouton de la manette… + Batterie %1$d%% + Dernière entrée + Stick gauche + Stick droit + Gâchettes L·R + Toutes les entrées sont détectées (%1$d testées) + %1$d / %2$d entrées vérifiées + Identifier + Auto + Détection automatique + Affichage / Partage + Menu / Options + Clic du stick gauche + Clic du stick droit + Croix directionnelle Haut + Croix directionnelle Bas + Croix directionnelle Gauche + Croix directionnelle Droite + Guide + … (Accès rapide) + Profil + — aucun — + — Aucun (Xbox natif) — + Renommer… + Supprimer… + + Nouveau + Nom du nouveau profil + Créer + Toutes les affectations + Sélectionnez ou créez un profil pour affecter cette manette. + Connectez une manette pour l\'affecter. Les affectations sont enregistrées par manette. + La réaffectation est active — %1$d sur %2$d affectés + Les boutons non affectés ne répondent plus en jeu : ils n\'agissent plus comme une manette Xbox native. + Remplir → natif + Affectations · %1$d sur %2$d affectés + natif + Affecter : + Actuellement : %1$s + natif (transmission directe) + Clavier + Souris + Xbox + Aucun + Natif (%1$s) + Effacer + L3 (clic du stick) + R3 (clic du stick) + Start + Retour + Guide + Croix directionnelle Haut + Croix directionnelle Bas + Croix directionnelle Gauche + Croix directionnelle Droite + Stick gauche Haut + Stick gauche Bas + Stick gauche Gauche + Stick gauche Droite + Stick droit Haut + Stick droit Bas + Stick droit Gauche + Stick droit Droite + Steam Controller + Prise en charge du Steam Controller + Lire un Steam Controller appairé via Bluetooth LE ou USB. Désactivé par défaut ; tant que l\'option est désactivée, le fonctionnement normal des manettes reste inchangé. + Souris trackpad + Désactivé + Pad droit + Pad gauche + Les deux pads + Boutons supplémentaires + L4 (en haut à gauche) + L5 (en bas à gauche) + R4 (en haut à droite) + R5 (en bas à droite) + … (Accès rapide) + Prend effet au prochain démarrage d\'un jeu. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index e13d3def9..25877e08d 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2566,4 +2566,88 @@ गेमपैड अद्यतित अपडेट उपलब्ध — %1$s + कंट्रोलर परीक्षण + कंट्रोलर परीक्षण और बाइंडिंग + पैड की तस्वीर पर हर इनपुट जाँचें और बटनों पर टैप करके उन्हें रीमैप करें + परीक्षण + बाइंड + लाइव इनपुट + पैड पर कोई बटन दबाएँ… + बैटरी %1$d%% + अंतिम इनपुट + बायाँ स्टिक + दायाँ स्टिक + ट्रिगर L·R + सभी इनपुट दर्ज हो रहे हैं (%1$d परीक्षित) + %1$d / %2$d इनपुट सत्यापित + पहचानें + स्वतः + स्वतः पता लगाएँ + व्यू / शेयर + मेनू / ऑप्शंस + बायाँ स्टिक क्लिक + दायाँ स्टिक क्लिक + D-pad ऊपर + D-pad नीचे + D-pad बाएँ + D-pad दाएँ + गाइड + … (त्वरित पहुँच) + प्रोफ़ाइल + — कोई नहीं — + — कोई नहीं (नेटिव Xbox) — + नाम बदलें… + हटाएँ… + + नया + नई प्रोफ़ाइल का नाम + बनाएँ + सभी बाइंडिंग + इस कंट्रोलर को बाइंड करने के लिए प्रोफ़ाइल चुनें या बनाएँ। + बाइंड करने के लिए कंट्रोलर कनेक्ट करें। बाइंडिंग प्रत्येक कंट्रोलर के लिए अलग से सहेजी जाती हैं। + रीमैपिंग चालू है — %2$d में से %1$d मैप किए गए + बिना बाइंड किए बटन गेम में निष्क्रिय हो जाते हैं: वे अब नेटिव Xbox पैड की तरह काम नहीं करते। + भरें → नेटिव + बाइंडिंग · %2$d में से %1$d मैप किए गए + नेटिव + बाइंड: + वर्तमान में: %1$s + नेटिव (पास-थ्रू) + कीबोर्ड + माउस + Xbox + कोई नहीं + नेटिव (%1$s) + साफ़ करें + L3 (स्टिक क्लिक) + R3 (स्टिक क्लिक) + स्टार्ट + बैक + गाइड + D-pad ऊपर + D-pad नीचे + D-pad बाएँ + D-pad दाएँ + बायाँ स्टिक ऊपर + बायाँ स्टिक नीचे + बायाँ स्टिक बाएँ + बायाँ स्टिक दाएँ + दायाँ स्टिक ऊपर + दायाँ स्टिक नीचे + दायाँ स्टिक बाएँ + दायाँ स्टिक दाएँ + Steam Controller + Steam Controller समर्थन + युग्मित Steam Controller को Bluetooth LE या USB पर पढ़ें। डिफ़ॉल्ट रूप से बंद; बंद रहने तक सामान्य कंट्रोलर पथ अछूता रहता है। + ट्रैकपैड माउस + बंद + दायाँ पैड + बायाँ पैड + दोनों पैड + अतिरिक्त बटन + L4 (ऊपरी बायाँ) + L5 (निचला बायाँ) + R4 (ऊपरी दायाँ) + R5 (निचला दायाँ) + … (त्वरित पहुँच) + अगली बार गेम शुरू होने पर प्रभावी होगा। diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 96e12189a..f33daebab 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2630,5 +2630,89 @@ Percorso installato: Gamepad Aggiornato Aggiornamento disponibile — %1$s + Test del controller + Test e associazione del controller + Verifica ogni input sull\'immagine del controller e rimappa i pulsanti toccandoli + Test + Associa + Input in tempo reale + Premi un pulsante sul controller… + Batteria %1$d%% + Ultimo input + Levetta sinistra + Levetta destra + Grilletti L·R + Tutti gli input rispondono (%1$d testati) + %1$d / %2$d input verificati + Identifica + Auto + Rilevamento automatico + Visualizza / Condividi + Menu / Opzioni + Clic levetta sinistra + Clic levetta destra + D-pad su + D-pad giù + D-pad sinistra + D-pad destra + Guida + … (Accesso rapido) + Profilo + — nessuno — + — Nessuno (Xbox nativo) — + Rinomina… + Elimina… + + Nuovo + Nome del nuovo profilo + Crea + Tutte le associazioni + Seleziona o crea un profilo per associare questo controller. + Collega un controller per associarlo. Le associazioni vengono salvate per ogni controller. + Rimappatura attiva — %1$d di %2$d mappati + I pulsanti non associati restano inattivi nel gioco: non funzionano più come su un controller Xbox nativo. + Riempi → nativo + Associazioni · %1$d di %2$d mappati + nativo + Associa: + Attualmente: %1$s + nativo (pass-through) + Tastiera + Mouse + Xbox + Nessuno + Nativo (%1$s) + Cancella + L3 (clic levetta) + R3 (clic levetta) + Start + Indietro + Guida + D-pad su + D-pad giù + D-pad sinistra + D-pad destra + Levetta sinistra su + Levetta sinistra giù + Levetta sinistra a sinistra + Levetta sinistra a destra + Levetta destra su + Levetta destra giù + Levetta destra a sinistra + Levetta destra a destra + Steam Controller + Supporto Steam Controller + Legge uno Steam Controller abbinato tramite Bluetooth LE o USB. Disattivo per impostazione predefinita; finché resta disattivo, il normale percorso dei controller non viene toccato. + Mouse trackpad + Disattivo + Pad destro + Pad sinistro + Entrambi i pad + Pulsanti extra + L4 (in alto a sinistra) + L5 (in basso a sinistra) + R4 (in alto a destra) + R5 (in basso a destra) + … (Accesso rapido) + Ha effetto al prossimo avvio di un gioco. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index bb2aba574..a555187d6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2629,4 +2629,89 @@ ゲームパッド 最新です アップデートあり — %1$s + + コントローラーテスト + コントローラーテストとバインド + パッドの図ですべての入力を確認し、ボタンをタップして割り当てを変更します + テスト + バインド + ライブ入力 + パッドのボタンを押してください… + バッテリー %1$d%% + 最後の入力 + 左スティック + 右スティック + トリガー L·R + すべての入力を検出しました (%1$d 項目をテスト済み) + %2$d 項目中 %1$d 項目の入力を確認済み + 識別 + 自動 + 自動検出 + ビュー / シェア + メニュー / オプション + 左スティック押し込み + 右スティック押し込み + 十字キー上 + 十字キー下 + 十字キー左 + 十字キー右 + ガイド + … (クイックアクセス) + プロファイル + — なし — + — なし (ネイティブ Xbox) — + 名前を変更… + 削除… + + 新規 + 新しいプロファイル名 + 作成 + すべてのバインド + このコントローラーをバインドするには、プロファイルを選択または作成してください。 + コントローラーを接続するとバインドできます。バインドはコントローラーごとに保存されます。 + 再割り当てが有効 — %2$d 個中 %1$d 個を割り当て済み + バインドされていないボタンはゲーム内で無効になります。ネイティブ Xbox パッドとしては動作しません。 + 一括設定 → ネイティブ + バインド · %2$d 個中 %1$d 個を割り当て済み + ネイティブ + バインド: + 現在: %1$s + ネイティブ (パススルー) + キーボード + マウス + Xbox + なし + ネイティブ (%1$s) + クリア + L3 (スティック押し込み) + R3 (スティック押し込み) + スタート + バック + ガイド + 十字キー上 + 十字キー下 + 十字キー左 + 十字キー右 + 左スティック上 + 左スティック下 + 左スティック左 + 左スティック右 + 右スティック上 + 右スティック下 + 右スティック左 + 右スティック右 + Steam Controller + Steam Controller のサポート + ペアリング済みの Steam Controller を Bluetooth LE または USB 経由で読み取ります。既定ではオフです。オフの間は通常のコントローラー経路には影響しません。 + トラックパッドマウス + オフ + 右パッド + 左パッド + 両方のパッド + 追加ボタン + L4 (左上) + L5 (左下) + R4 (右上) + R5 (右下) + … (クイックアクセス) + 次回のゲーム起動時に反映されます。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6b0405bf0..e44477fdd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2631,5 +2631,90 @@ 게임패드 최신 상태 업데이트 있음 — %1$s + + 컨트롤러 테스트 + 컨트롤러 테스트 및 바인딩 + 패드 그림에서 모든 입력을 확인하고 버튼을 탭하여 다시 매핑하세요 + 테스트 + 바인딩 + 실시간 입력 + 패드의 버튼을 누르세요… + 배터리 %1$d%% + 마지막 입력 + 왼쪽 스틱 + 오른쪽 스틱 + 트리거 L·R + 모든 입력이 인식됨 (%1$d개 테스트함) + 입력 %2$d개 중 %1$d개 확인됨 + 식별 + 자동 + 자동 감지 + 보기 / 공유 + 메뉴 / 옵션 + 왼쪽 스틱 누르기 + 오른쪽 스틱 누르기 + D-패드 위 + D-패드 아래 + D-패드 왼쪽 + D-패드 오른쪽 + 가이드 + … (빠른 액세스) + 프로필 + — 없음 — + — 없음 (네이티브 Xbox) — + 이름 변경… + 삭제… + + 새로 만들기 + 새 프로필 이름 + 만들기 + 모든 바인딩 + 이 컨트롤러를 바인딩하려면 프로필을 선택하거나 새로 만드세요. + 바인딩하려면 컨트롤러를 연결하세요. 바인딩은 컨트롤러별로 저장됩니다. + 다시 매핑 사용 중 — %2$d개 중 %1$d개 매핑됨 + 바인딩되지 않은 버튼은 게임에서 동작하지 않습니다. 더 이상 네이티브 Xbox 패드로 작동하지 않습니다. + 일괄 설정 → 네이티브 + 바인딩 · %2$d개 중 %1$d개 매핑됨 + 네이티브 + 바인딩: + 현재: %1$s + 네이티브 (패스스루) + 키보드 + 마우스 + Xbox + 없음 + 네이티브 (%1$s) + 지우기 + L3 (스틱 누르기) + R3 (스틱 누르기) + 시작 + 뒤로 + 가이드 + D-패드 위 + D-패드 아래 + D-패드 왼쪽 + D-패드 오른쪽 + 왼쪽 스틱 위 + 왼쪽 스틱 아래 + 왼쪽 스틱 왼쪽 + 왼쪽 스틱 오른쪽 + 오른쪽 스틱 위 + 오른쪽 스틱 아래 + 오른쪽 스틱 왼쪽 + 오른쪽 스틱 오른쪽 + Steam Controller + Steam Controller 지원 + 페어링된 Steam Controller를 Bluetooth LE 또는 USB로 읽습니다. 기본값은 끄기이며, 꺼져 있는 동안에는 일반 컨트롤러 경로에 영향을 주지 않습니다. + 트랙패드 마우스 + 끄기 + 오른쪽 패드 + 왼쪽 패드 + 양쪽 패드 + 추가 버튼 + L4 (왼쪽 위) + L5 (왼쪽 아래) + R4 (오른쪽 위) + R5 (오른쪽 아래) + … (빠른 액세스) + 게임을 다시 시작할 때 적용됩니다. diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 2be9322b2..536563a4c 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -2629,4 +2629,89 @@ Installert bane: Spillkontroller Oppdatert Oppdatering tilgjengelig — %1$s + + Kontrollertest + Kontrollertest og tilordning + Kontroller hver inndata på kontrollerbildet, og tilordne knapper på nytt ved å trykke på dem + Test + Tilordne + Sanntidsinndata + Trykk på en knapp på kontrolleren… + Batteri %1$d%% + Siste inndata + Venstre spak + Høyre spak + Avtrekkere L·R + Alle inndata registreres (%1$d testet) + %1$d / %2$d inndata verifisert + Identifiser + Auto + Oppdag automatisk + Vis / Del + Meny / Alternativer + Venstre spak-klikk + Høyre spak-klikk + D-pad opp + D-pad ned + D-pad venstre + D-pad høyre + Guide + … (Hurtigtilgang) + Profil + — ingen — + — Ingen (innebygd Xbox) — + Gi nytt navn… + Slett… + + Ny + Navn på ny profil + Opprett + Alle tilordninger + Velg eller opprett en profil for å tilordne denne kontrolleren. + Koble til en kontroller for å tilordne den. Tilordninger lagres per kontroller. + Omdefinering er på — %1$d av %2$d tilordnet + Knapper uten tilordning blir stille i spillet: de fungerer ikke lenger som en innebygd Xbox-kontroller. + Fyll → innebygd + Tilordninger · %1$d av %2$d tilordnet + innebygd + Tilordne: + Gjeldende: %1$s + innebygd (direkte) + Tastatur + Mus + Xbox + Ingen + Innebygd (%1$s) + Tøm + L3 (spak-klikk) + R3 (spak-klikk) + Start + Tilbake + Guide + D-pad opp + D-pad ned + D-pad venstre + D-pad høyre + Venstre spak opp + Venstre spak ned + Venstre spak venstre + Venstre spak høyre + Høyre spak opp + Høyre spak ned + Høyre spak venstre + Høyre spak høyre + Steam Controller + Støtte for Steam Controller + Les en sammenkoblet Steam Controller over Bluetooth LE eller USB. Av som standard; den vanlige kontrollerbanen berøres ikke mens den er av. + Styreflate-mus + Av + Høyre styreflate + Venstre styreflate + Begge styreflater + Ekstra knapper + L4 (øverst til venstre) + L5 (nederst til venstre) + R4 (øverst til høyre) + R5 (nederst til høyre) + … (Hurtigtilgang) + Trer i kraft neste gang et spill starter. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 520306f50..a8d2331e0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2636,5 +2636,90 @@ Zainstalowana ścieżka: Gamepad Aktualna wersja Dostępna aktualizacja — %1$s + + Test kontrolera + Test i przypisania kontrolera + Sprawdź każde wejście na obrazie kontrolera i zmień przypisania przycisków, dotykając ich + Test + Przypisz + Wejście na żywo + Naciśnij przycisk na kontrolerze… + Bateria %1$d%% + Ostatnie wejście + Lewa gałka + Prawa gałka + Spusty L·R + Wszystkie wejścia działają (przetestowano %1$d) + Zweryfikowano %1$d / %2$d wejść + Identyfikuj + Auto + Autowykrywanie + Widok / Udostępnianie + Menu / Opcje + Kliknięcie lewej gałki + Kliknięcie prawej gałki + D-pad w górę + D-pad w dół + D-pad w lewo + D-pad w prawo + Przycisk Guide + … (Szybki dostęp) + Profil + — brak — + — Brak (natywny Xbox) — + Zmień nazwę… + Usuń… + + Nowy + Nazwa nowego profilu + Utwórz + Wszystkie przypisania + Wybierz lub utwórz profil, aby przypisać ten kontroler. + Podłącz kontroler, aby go przypisać. Przypisania są zapisywane osobno dla każdego kontrolera. + Zmiana przypisań jest włączona — przypisano %1$d z %2$d + Nieprzypisane przyciski przestają działać w grze: nie zachowują się już jak natywny pad Xbox. + Wypełnij → natywne + Przypisania · przypisano %1$d z %2$d + natywne + Przypisz: + Obecnie: %1$s + natywne (przekazywanie bezpośrednie) + Klawiatura + Mysz + Xbox + Brak + Natywne (%1$s) + Wyczyść + L3 (kliknięcie gałki) + R3 (kliknięcie gałki) + Przycisk Start + Przycisk Back + Przycisk Guide + D-pad w górę + D-pad w dół + D-pad w lewo + D-pad w prawo + Lewa gałka w górę + Lewa gałka w dół + Lewa gałka w lewo + Lewa gałka w prawo + Prawa gałka w górę + Prawa gałka w dół + Prawa gałka w lewo + Prawa gałka w prawo + Steam Controller + Obsługa Steam Controller + Odczytuje sparowany kontroler Steam Controller przez Bluetooth LE lub USB. Domyślnie wyłączone; gdy jest wyłączone, zwykła obsługa kontrolerów pozostaje nienaruszona. + Mysz na gładziku + Wyłączone + Prawy gładzik + Lewy gładzik + Oba gładziki + Dodatkowe przyciski + L4 (lewy górny) + L5 (lewy dolny) + R4 (prawy górny) + R5 (prawy dolny) + … (Szybki dostęp) + Zmiana zacznie obowiązywać przy następnym uruchomieniu gry. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3d7ddd4d7..7045bd0cd 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2630,5 +2630,89 @@ Caminho instalado: Controle Atualizado Atualização disponível — %1$s + Teste do controle + Teste e vinculação do controle + Verifique cada entrada na imagem do controle e remapeie os botões tocando neles + Teste + Vincular + Entrada em tempo real + Pressione um botão no controle… + Bateria %1$d%% + Última entrada + Analógico esquerdo + Analógico direito + Gatilhos L·R + Todas as entradas respondendo (%1$d testadas) + %1$d / %2$d entradas verificadas + Identificar + Auto + Detecção automática + Visualizar / Compartilhar + Menu / Opções + Clique do analógico esquerdo + Clique do analógico direito + D-pad cima + D-pad baixo + D-pad esquerda + D-pad direita + Guia + … (Acesso rápido) + Perfil + — nenhum — + — Nenhum (Xbox nativo) — + Renomear… + Excluir… + + Novo + Nome do novo perfil + Criar + Todas as vinculações + Selecione ou crie um perfil para vincular este controle. + Conecte um controle para vinculá-lo. As vinculações são salvas por controle. + Remapeamento ligado — %1$d de %2$d mapeados + Os botões sem vinculação ficam inativos no jogo: deixam de funcionar como um controle Xbox nativo. + Preencher → nativo + Vinculações · %1$d de %2$d mapeados + nativo + Vincular: + Atualmente: %1$s + nativo (pass-through) + Teclado + Mouse + Xbox + Nenhum + Nativo (%1$s) + Limpar + L3 (clique do analógico) + R3 (clique do analógico) + Start + Voltar + Guia + D-pad cima + D-pad baixo + D-pad esquerda + D-pad direita + Analógico esquerdo cima + Analógico esquerdo baixo + Analógico esquerdo esquerda + Analógico esquerdo direita + Analógico direito cima + Analógico direito baixo + Analógico direito esquerda + Analógico direito direita + Steam Controller + Suporte a Steam Controller + Lê um Steam Controller pareado via Bluetooth LE ou USB. Desligado por padrão; enquanto estiver desligado, o caminho normal dos controles não é afetado. + Mouse do trackpad + Desligado + Pad direito + Pad esquerdo + Ambos os pads + Botões extras + L4 (superior esquerdo) + L5 (inferior esquerdo) + R4 (superior direito) + R5 (inferior direito) + … (Acesso rápido) + Entra em vigor na próxima vez que um jogo for iniciado. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2d61bfe0d..ef0d08a6c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2629,4 +2629,88 @@ Caminho instalado: Comando Atualizado Atualização disponível — %1$s + Teste do comando + Teste e associação do comando + Verifique cada entrada na imagem do comando e remapeie os botões tocando neles + Teste + Associar + Entrada em tempo real + Prima um botão no comando… + Bateria %1$d%% + Última entrada + Analógico esquerdo + Analógico direito + Gatilhos L·R + Todas as entradas a responder (%1$d testadas) + %1$d / %2$d entradas verificadas + Identificar + Auto + Deteção automática + Ver / Partilhar + Menu / Opções + Clique do analógico esquerdo + Clique do analógico direito + D-pad cima + D-pad baixo + D-pad esquerda + D-pad direita + Guia + … (Acesso rápido) + Perfil + — nenhum — + — Nenhum (Xbox nativo) — + Mudar nome… + Eliminar… + + Novo + Nome do novo perfil + Criar + Todas as associações + Selecione ou crie um perfil para associar este comando. + Ligue um comando para o associar. As associações são guardadas por comando. + Remapeamento ligado — %1$d de %2$d mapeados + Os botões sem associação ficam inativos no jogo: deixam de funcionar como um comando Xbox nativo. + Preencher → nativo + Associações · %1$d de %2$d mapeados + nativo + Associar: + Atualmente: %1$s + nativo (pass-through) + Teclado + Rato + Xbox + Nenhum + Nativo (%1$s) + Limpar + L3 (clique do analógico) + R3 (clique do analógico) + Start + Voltar + Guia + D-pad cima + D-pad baixo + D-pad esquerda + D-pad direita + Analógico esquerdo cima + Analógico esquerdo baixo + Analógico esquerdo esquerda + Analógico esquerdo direita + Analógico direito cima + Analógico direito baixo + Analógico direito esquerda + Analógico direito direita + Steam Controller + Suporte para Steam Controller + Lê um Steam Controller emparelhado através de Bluetooth LE ou USB. Desligado por predefinição; enquanto estiver desligado, o percurso normal dos comandos não é afetado. + Rato do trackpad + Desligado + Pad direito + Pad esquerdo + Ambos os pads + Botões extra + L4 (superior esquerdo) + L5 (inferior esquerdo) + R4 (superior direito) + R5 (inferior direito) + … (Acesso rápido) + Tem efeito no próximo arranque de um jogo. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c82fc4f9b..390c6e369 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2629,5 +2629,90 @@ Cale instalata: Gamepad La zi Actualizare disponibilă — %1$s + + Test controller + Test și asociere controller + Verifică fiecare intrare pe imaginea controllerului și reasociază butoanele atingându-le + Test + Asociază + Intrare live + Apasă un buton pe controller… + Baterie %1$d%% + Ultima intrare + Stick stânga + Stick dreapta + Triggere L·R + Toate intrările răspund (%1$d testate) + %1$d / %2$d intrări verificate + Identifică + Auto + Detectare automată + Vizualizare / Partajare + Meniu / Opțiuni + Clic stick stânga + Clic stick dreapta + D-pad sus + D-pad jos + D-pad stânga + D-pad dreapta + Buton Guide + … (Acces rapid) + Profil + — niciunul — + — Niciunul (Xbox nativ) — + Redenumește… + Șterge… + + Nou + Nume profil nou + Creează + Toate asocierile + Selectează sau creează un profil pentru a asocia acest controller. + Conectează un controller pentru a-l asocia. Asocierile sunt salvate pentru fiecare controller în parte. + Reasocierea este activă — %1$d din %2$d asociate + Butoanele neasociate rămân inactive în joc: nu mai funcționează ca un controller Xbox nativ. + Completează → nativ + Asocieri · %1$d din %2$d asociate + nativ + Asociază: + În prezent: %1$s + nativ (transmitere directă) + Tastatură + Mouse + Xbox + Niciunul + Nativ (%1$s) + Golește + L3 (clic stick) + R3 (clic stick) + Buton Start + Buton Back + Buton Guide + D-pad sus + D-pad jos + D-pad stânga + D-pad dreapta + Stick stânga sus + Stick stânga jos + Stick stânga spre stânga + Stick stânga spre dreapta + Stick dreapta sus + Stick dreapta jos + Stick dreapta spre stânga + Stick dreapta spre dreapta + Steam Controller + Suport Steam Controller + Citește un Steam Controller asociat prin Bluetooth LE sau USB. Dezactivat implicit; cât timp este dezactivat, calea obișnuită pentru controllere rămâne neatinsă. + Mouse pe trackpad + Dezactivat + Trackpad dreapta + Trackpad stânga + Ambele trackpad-uri + Butoane suplimentare + L4 (stânga sus) + L5 (stânga jos) + R4 (dreapta sus) + R5 (dreapta jos) + … (Acces rapid) + Are efect la următoarea pornire a unui joc. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 09761817d..298877a4f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2536,4 +2536,88 @@ Геймпад Актуальная версия Доступно обновление — %1$s + Тест контроллера + Тест и привязка контроллера + Проверьте каждый элемент управления на изображении геймпада и переназначьте кнопки касанием + Тест + Привязка + Ввод в реальном времени + Нажмите кнопку на геймпаде… + Батарея %1$d%% + Последний ввод + Левый стик + Правый стик + Триггеры L·R + Все элементы управления работают (проверено: %1$d) + Проверено элементов: %1$d / %2$d + Определить + Авто + Автоопределение + Просмотр / Поделиться + Меню / Параметры + Нажатие левого стика + Нажатие правого стика + D-pad вверх + D-pad вниз + D-pad влево + D-pad вправо + Гид + … (Быстрый доступ) + Профиль + — нет — + — Нет (родной Xbox) — + Переименовать… + Удалить… + + Создать + Имя нового профиля + Создать + Все привязки + Выберите или создайте профиль, чтобы задать привязки для этого контроллера. + Подключите контроллер, чтобы задать привязки. Привязки сохраняются отдельно для каждого контроллера. + Переназначение включено — назначено %1$d из %2$d + Непривязанные кнопки в игре не работают: они больше не действуют как на родном геймпаде Xbox. + Заполнить → родными + Привязки · назначено %1$d из %2$d + родная + Привязать: + Сейчас: %1$s + родная (сквозная передача) + Клавиатура + Мышь + Xbox + Нет + Родная (%1$s) + Очистить + L3 (нажатие стика) + R3 (нажатие стика) + Старт + Назад + Гид + D-pad вверх + D-pad вниз + D-pad влево + D-pad вправо + Левый стик вверх + Левый стик вниз + Левый стик влево + Левый стик вправо + Правый стик вверх + Правый стик вниз + Правый стик влево + Правый стик вправо + Steam Controller + Поддержка Steam Controller + Чтение сопряжённого Steam Controller через Bluetooth LE или USB. По умолчанию выключено; пока опция выключена, обычная обработка контроллеров не затрагивается. + Мышь на трекпаде + Выкл. + Правый трекпад + Левый трекпад + Оба трекпада + Дополнительные кнопки + L4 (верхняя левая) + L5 (нижняя левая) + R4 (верхняя правая) + R5 (нижняя правая) + … (Быстрый доступ) + Изменения вступят в силу при следующем запуске игры. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 93402b4e1..1a20b28c1 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2629,4 +2629,89 @@ Installerad sökväg: Handkontroll Uppdaterad Uppdatering tillgänglig — %1$s + + Handkontrolltest + Handkontrolltest och bindning + Kontrollera varje inmatning på handkontrollbilden och mappa om knappar genom att trycka på dem + Test + Bind + Live-inmatning + Tryck på en knapp på handkontrollen… + Batteri %1$d%% + Senaste inmatning + Vänster spak + Höger spak + Avtryckare L·R + Alla inmatningar registreras (%1$d testade) + %1$d / %2$d inmatningar verifierade + Identifiera + Auto + Upptäck automatiskt + Visa / Dela + Meny / Alternativ + Vänster spakklick + Höger spakklick + Styrkors upp + Styrkors ner + Styrkors vänster + Styrkors höger + Guide + … (Snabbåtkomst) + Profil + — ingen — + — Ingen (inbyggd Xbox) — + Byt namn… + Ta bort… + + Ny + Namn på ny profil + Skapa + Alla bindningar + Välj eller skapa en profil för att binda den här handkontrollen. + Anslut en handkontroll för att binda den. Bindningar sparas per handkontroll. + Ommappning är på — %1$d av %2$d mappade + Obundna knappar blir tysta i spelet: de fungerar inte längre som en inbyggd Xbox-handkontroll. + Fyll → inbyggd + Bindningar · %1$d av %2$d mappade + inbyggd + Bind: + Aktuellt: %1$s + inbyggd (direkt) + Tangentbord + Mus + Xbox + Ingen + Inbyggd (%1$s) + Rensa + L3 (spakklick) + R3 (spakklick) + Start + Bakåt + Guide + Styrkors upp + Styrkors ner + Styrkors vänster + Styrkors höger + Vänster spak upp + Vänster spak ner + Vänster spak vänster + Vänster spak höger + Höger spak upp + Höger spak ner + Höger spak vänster + Höger spak höger + Steam Controller + Stöd för Steam Controller + Läs en parkopplad Steam Controller via Bluetooth LE eller USB. Av som standard; den vanliga handkontrollvägen påverkas inte medan den är av. + Styrplattemus + Av + Höger styrplatta + Vänster styrplatta + Båda styrplattorna + Extra knappar + L4 (uppe till vänster) + L5 (nere till vänster) + R4 (uppe till höger) + R5 (nere till höger) + … (Snabbåtkomst) + Träder i kraft nästa gång ett spel startar. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index b936767f4..744c273fe 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -2629,4 +2629,88 @@ เกมแพด เป็นเวอร์ชันล่าสุด มีอัปเดต — %1$s + ทดสอบคอนโทรลเลอร์ + ทดสอบคอนโทรลเลอร์และการผูกปุ่ม + ตรวจสอบอินพุตทุกรายการบนภาพแพด และรีแมปปุ่มโดยแตะที่ปุ่มนั้น + ทดสอบ + ผูกปุ่ม + อินพุตสด + กดปุ่มใดก็ได้บนแพด… + แบตเตอรี่ %1$d%% + อินพุตล่าสุด + สติกซ้าย + สติกขวา + ทริกเกอร์ L·R + อินพุตทั้งหมดทำงาน (ทดสอบแล้ว %1$d รายการ) + ยืนยันอินพุตแล้ว %1$d / %2$d + ระบุตัวตน + อัตโนมัติ + ตรวจหาอัตโนมัติ + วิว / แชร์ + เมนู / ตัวเลือก + คลิกสติกซ้าย + คลิกสติกขวา + D-pad ขึ้น + D-pad ลง + D-pad ซ้าย + D-pad ขวา + ไกด์ + … (การเข้าถึงด่วน) + โปรไฟล์ + — ไม่มี — + — ไม่มี (Xbox เนทีฟ) — + เปลี่ยนชื่อ… + ลบ… + + ใหม่ + ชื่อโปรไฟล์ใหม่ + สร้าง + การผูกปุ่มทั้งหมด + เลือกหรือสร้างโปรไฟล์เพื่อผูกปุ่มคอนโทรลเลอร์นี้ + เชื่อมต่อคอนโทรลเลอร์เพื่อผูกปุ่ม การผูกปุ่มจะถูกบันทึกแยกตามคอนโทรลเลอร์แต่ละตัว + เปิดการรีแมปอยู่ — แมปแล้ว %1$d จาก %2$d + ปุ่มที่ไม่ได้ผูกจะเงียบในเกม: ปุ่มเหล่านั้นจะไม่ทำงานเหมือนแพด Xbox เนทีฟอีกต่อไป + เติม → เนทีฟ + การผูกปุ่ม · แมปแล้ว %1$d จาก %2$d + เนทีฟ + ผูกปุ่ม: + ปัจจุบัน: %1$s + เนทีฟ (ส่งผ่าน) + คีย์บอร์ด + เมาส์ + Xbox + ไม่มี + เนทีฟ (%1$s) + ล้าง + L3 (คลิกสติก) + R3 (คลิกสติก) + สตาร์ท + แบ็ค + ไกด์ + D-pad ขึ้น + D-pad ลง + D-pad ซ้าย + D-pad ขวา + สติกซ้ายขึ้น + สติกซ้ายลง + สติกซ้ายไปทางซ้าย + สติกซ้ายไปทางขวา + สติกขวาขึ้น + สติกขวาลง + สติกขวาไปทางซ้าย + สติกขวาไปทางขวา + Steam Controller + รองรับ Steam Controller + อ่านค่าจาก Steam Controller ที่จับคู่ไว้ผ่าน Bluetooth LE หรือ USB ปิดไว้ตามค่าเริ่มต้น ขณะที่ปิดอยู่ เส้นทางคอนโทรลเลอร์ปกติจะไม่ถูกแตะต้อง + เมาส์แทร็กแพด + ปิด + แพดขวา + แพดซ้าย + ทั้งสองแพด + ปุ่มพิเศษ + L4 (บนซ้าย) + L5 (ล่างซ้าย) + R4 (บนขวา) + R5 (ล่างขวา) + … (การเข้าถึงด่วน) + มีผลในครั้งถัดไปที่เริ่มเกม diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0031f4706..0a1401565 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2629,4 +2629,89 @@ Yüklü konum: Gamepad Güncel Güncelleme mevcut — %1$s + + Kumanda testi + Kumanda testi ve bağlama + Kumanda görselindeki her girişi kontrol edin ve düğmelere dokunarak yeniden eşleyin + Test + Bağla + Canlı giriş + Kumandada bir düğmeye basın… + Pil %%%1$d + Son giriş + Sol çubuk + Sağ çubuk + Tetikler L·R + Tüm girişler algılanıyor (%1$d test edildi) + %1$d / %2$d giriş doğrulandı + Tanımla + Otomatik + Otomatik algıla + Görünüm / Paylaş + Menü / Seçenekler + Sol çubuk tıklaması + Sağ çubuk tıklaması + Yön Tuşu Yukarı + Yön Tuşu Aşağı + Yön Tuşu Sol + Yön Tuşu Sağ + Guide düğmesi + … (Hızlı Erişim) + Profil + — yok — + — Yok (yerel Xbox) — + Yeniden Adlandır… + Sil… + + Yeni + Yeni profil adı + Oluştur + Tüm bağlamalar + Bu kumandayı bağlamak için bir profil seçin veya oluşturun. + Bağlama yapmak için bir kumanda bağlayın. Bağlamalar her kumanda için ayrı kaydedilir. + Yeniden eşleme açık — %2$d düğmeden %1$d tanesi eşlendi + Bağlanmamış düğmeler oyun içinde sessiz kalır: artık yerel bir Xbox kumandası gibi davranmazlar. + Doldur → yerel + Bağlamalar · %2$d düğmeden %1$d tanesi eşlendi + yerel + Bağla: + Şu anda: %1$s + yerel (doğrudan aktarım) + Klavye + Fare + Xbox + Yok + Yerel (%1$s) + Temizle + L3 (çubuk tıklaması) + R3 (çubuk tıklaması) + Start düğmesi + Back düğmesi + Guide düğmesi + Yön Tuşu Yukarı + Yön Tuşu Aşağı + Yön Tuşu Sol + Yön Tuşu Sağ + Sol çubuk yukarı + Sol çubuk aşağı + Sol çubuk sol + Sol çubuk sağ + Sağ çubuk yukarı + Sağ çubuk aşağı + Sağ çubuk sol + Sağ çubuk sağ + Steam Controller + Steam Controller desteği + Eşleştirilmiş bir Steam Controller\'ı Bluetooth LE veya USB üzerinden okur. Varsayılan olarak kapalıdır; kapalıyken normal kumanda yoluna dokunulmaz. + Dokunmatik yüzey faresi + Kapalı + Sağ dokunmatik yüzey + Sol dokunmatik yüzey + Her iki dokunmatik yüzey + Ek düğmeler + L4 (sol üst) + L5 (sol alt) + R4 (sağ üst) + R5 (sağ alt) + … (Hızlı Erişim) + Bir sonraki oyun başlatıldığında geçerli olur. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 21782986a..45b4f0aa4 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2636,5 +2636,89 @@ Геймпад Актуальна версія Доступне оновлення — %1$s + Тест контролера + Тест і прив\'язка контролера + Перевірте кожен елемент керування на зображенні геймпада та перепризначте кнопки дотиком + Тест + Прив\'язка + Введення в реальному часі + Натисніть кнопку на геймпаді… + Батарея %1$d%% + Останнє введення + Лівий стік + Правий стік + Тригери L·R + Усі елементи керування працюють (перевірено: %1$d) + Перевірено елементів: %1$d / %2$d + Визначити + Авто + Автовизначення + Перегляд / Поділитися + Меню / Параметри + Натискання лівого стіка + Натискання правого стіка + D-pad вгору + D-pad вниз + D-pad вліво + D-pad вправо + Гід + … (Швидкий доступ) + Профіль + — немає — + — Немає (нативний Xbox) — + Перейменувати… + Видалити… + + Створити + Назва нового профілю + Створити + Усі прив\'язки + Виберіть або створіть профіль, щоб задати прив\'язки для цього контролера. + Підключіть контролер, щоб задати прив\'язки. Прив\'язки зберігаються окремо для кожного контролера. + Перепризначення увімкнено — призначено %1$d з %2$d + Неприв\'язані кнопки у грі не працюють: вони більше не діють як на нативному геймпаді Xbox. + Заповнити → нативними + Прив\'язки · призначено %1$d з %2$d + нативна + Прив\'язати: + Зараз: %1$s + нативна (наскрізна передача) + Клавіатура + Миша + Xbox + Немає + Нативна (%1$s) + Очистити + L3 (натискання стіка) + R3 (натискання стіка) + Старт + Назад + Гід + D-pad вгору + D-pad вниз + D-pad вліво + D-pad вправо + Лівий стік вгору + Лівий стік вниз + Лівий стік вліво + Лівий стік вправо + Правий стік вгору + Правий стік вниз + Правий стік вліво + Правий стік вправо + Steam Controller + Підтримка Steam Controller + Читання спареного Steam Controller через Bluetooth LE або USB. Типово вимкнено; доки опція вимкнена, звичайна обробка контролерів не змінюється. + Миша на трекпаді + Вимк. + Правий трекпад + Лівий трекпад + Обидва трекпади + Додаткові кнопки + L4 (верхня ліва) + L5 (нижня ліва) + R4 (верхня права) + R5 (нижня права) + … (Швидкий доступ) + Зміни наберуть чинності під час наступного запуску гри. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3cccb269a..7ed0d1e84 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2630,5 +2630,89 @@ 手柄 已是最新 有可用更新 — %1$s + 控制器测试 + 控制器测试与绑定 + 在手柄图示上检查每个输入,点按按钮即可重新映射 + 测试 + 绑定 + 实时输入 + 请按手柄上的任意按钮… + 电量 %1$d%% + 最近输入 + 左摇杆 + 右摇杆 + 扳机 L·R + 所有输入均有响应(已测试 %1$d 个) + 已验证 %1$d / %2$d 个输入 + 识别 + 自动 + 自动检测 + 视图 / 分享 + 菜单 / 选项 + 左摇杆按下 + 右摇杆按下 + 十字键 上 + 十字键 下 + 十字键 左 + 十字键 右 + 主页键 + …(快速访问) + 配置文件 + — 无 — + — 无(原生 Xbox) — + 重命名… + 删除… + + 新建 + 新配置文件名称 + 创建 + 所有绑定 + 请选择或创建一个配置文件以绑定此控制器。 + 请连接控制器以进行绑定。绑定按控制器分别保存。 + 重新映射已开启 — 已映射 %1$d / %2$d + 未绑定的按钮在游戏中不会有任何反应:它们不再作为原生 Xbox 手柄使用。 + 填充 → 原生 + 绑定 · 已映射 %1$d / %2$d + 原生 + 绑定: + 当前:%1$s + 原生(直通) + 键盘 + 鼠标 + Xbox + + 原生(%1$s) + 清除 + L3(摇杆按下) + R3(摇杆按下) + 开始 + 返回 + 主页键 + 十字键 上 + 十字键 下 + 十字键 左 + 十字键 右 + 左摇杆 上 + 左摇杆 下 + 左摇杆 左 + 左摇杆 右 + 右摇杆 上 + 右摇杆 下 + 右摇杆 左 + 右摇杆 右 + Steam Controller + Steam Controller 支持 + 通过 Bluetooth LE 或 USB 读取已配对的 Steam Controller。默认关闭;关闭时不会影响常规控制器通路。 + 触摸板鼠标 + + 右触摸板 + 左触摸板 + 双触摸板 + 额外按钮 + L4(左上) + L5(左下) + R4(右上) + R5(右下) + …(快速访问) + 下次启动游戏时生效。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ea5589adf..b97be4ef0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2629,5 +2629,89 @@ 手把 已是最新 有可用更新 — %1$s + 控制器測試 + 控制器測試與綁定 + 在手把圖示上檢查每個輸入,點按按鈕即可重新對應 + 測試 + 綁定 + 即時輸入 + 請按下手把上的任意按鈕… + 電量 %1$d%% + 最近輸入 + 左搖桿 + 右搖桿 + 扳機 L·R + 所有輸入皆有反應(已測試 %1$d 個) + 已驗證 %1$d / %2$d 個輸入 + 識別 + 自動 + 自動偵測 + 檢視 / 分享 + 選單 / 選項 + 左搖桿按下 + 右搖桿按下 + 方向鍵 上 + 方向鍵 下 + 方向鍵 左 + 方向鍵 右 + 主頁鍵 + …(快速存取) + 設定檔 + — 無 — + — 無(原生 Xbox) — + 重新命名… + 刪除… + + 新增 + 新設定檔名稱 + 建立 + 所有綁定 + 請選擇或建立設定檔以綁定此控制器。 + 請連接控制器以進行綁定。綁定會依控制器分別儲存。 + 重新對應已開啟 — 已對應 %1$d / %2$d + 未綁定的按鈕在遊戲中不會有任何反應:它們不再作為原生 Xbox 手把運作。 + 填滿 → 原生 + 綁定 · 已對應 %1$d / %2$d + 原生 + 綁定: + 目前:%1$s + 原生(直通) + 鍵盤 + 滑鼠 + Xbox + + 原生(%1$s) + 清除 + L3(搖桿按下) + R3(搖桿按下) + 開始 + 返回 + 主頁鍵 + 方向鍵 上 + 方向鍵 下 + 方向鍵 左 + 方向鍵 右 + 左搖桿 上 + 左搖桿 下 + 左搖桿 左 + 左搖桿 右 + 右搖桿 上 + 右搖桿 下 + 右搖桿 左 + 右搖桿 右 + Steam Controller + Steam Controller 支援 + 透過 Bluetooth LE 或 USB 讀取已配對的 Steam Controller。預設為關閉;關閉時不會影響一般控制器路徑。 + 觸控板滑鼠 + 關閉 + 右觸控板 + 左觸控板 + 雙觸控板 + 額外按鈕 + L4(左上) + L5(左下) + R4(右上) + R5(右下) + …(快速存取) + 下次啟動遊戲時生效。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fe86d3dd..a58b65480 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2702,4 +2702,89 @@ Installed path: Hardware address reported for the primary adapter. Leave empty for the automatic device-derived address. Automatic: %1$s Enter 12 hex digits (for example 3c:5a:b4:12:34:56). The first byte must be unicast and globally administered, so bits 0 and 1 clear; many games reject locally administered addresses such as 02:…. + + Controller test + Controller test & binding + Check every input on the pad picture and remap buttons by tapping them + Test + Bind + Live input + Press a button on the pad… + Battery %1$d%% + Last input + Left stick + Right stick + Triggers L·R + All inputs registering (%1$d tested) + %1$d / %2$d inputs verified + Identify + Auto + Auto-detect + View / Share + Menu / Options + Left stick click + Right stick click + D-pad up + D-pad down + D-pad left + D-pad right + Guide + … (Quick Access) + Profile + — none — + — None (native Xbox) — + Rename… + Delete… + + New + New profile name + Create + All bindings + Select or create a profile to bind this controller. + Connect a controller to bind it. Bindings are saved per controller. + Remapping is on — %1$d of %2$d mapped + Unbound buttons go silent in-game: they no longer act as a native Xbox pad. + Fill → native + Bindings · %1$d of %2$d mapped + native + Bind: + Currently: %1$s + native (pass-through) + Keyboard + Mouse + Xbox + None + Native (%1$s) + Clear + L3 (stick click) + R3 (stick click) + Start + Back + Guide + D-pad up + D-pad down + D-pad left + D-pad right + Left stick up + Left stick down + Left stick left + Left stick right + Right stick up + Right stick down + Right stick left + Right stick right + Steam Controller + Steam Controller support + Read a paired Steam Controller over Bluetooth LE or USB. Off by default; the normal controller path is untouched while it is off. + Trackpad mouse + Off + Right pad + Left pad + Both pads + Extra buttons + L4 (upper left) + L5 (lower left) + R4 (upper right) + R5 (lower right) + … (Quick Access) + Takes effect the next time a game starts. diff --git a/app/src/main/runtime/display/ControllerTestHost.kt b/app/src/main/runtime/display/ControllerTestHost.kt new file mode 100644 index 000000000..6aaa946a3 --- /dev/null +++ b/app/src/main/runtime/display/ControllerTestHost.kt @@ -0,0 +1,61 @@ +package com.winlator.cmod.runtime.display + +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.winlator.cmod.runtime.input.controls.ControlsProfile +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.controllertest.ControllerTestDialog + +object ControllerTestHost { + @JvmStatic + fun attach( + view: ComposeView, + activity: XServerDisplayActivity, + onDismiss: Runnable, + ) { + view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + view.setContent { + var revision by remember { mutableIntStateOf(0) } + val manager = activity.controllerTestProfileManager + val profiles: List = + remember(revision, manager) { manager?.getProfiles(true)?.toList() ?: emptyList() } + val active: ControlsProfile? = + remember(revision) { activity.inputControlsView?.profile } + + WinNativeTheme( + colorScheme = + darkColorScheme( + primary = Color(0xFF1A9FFF), + background = Color(0xFF18181D), + surface = Color(0xFF1C1C2A), + ), + ) { + ControllerTestDialog( + onDismiss = { onDismiss.run() }, + profile = active, + allProfiles = profiles, + onSelectProfile = { profile -> + activity.applyControllerTestProfile(profile) + revision++ + }, + onCreateProfile = { name -> + val created = manager?.createProfile(name) + if (created != null) activity.applyControllerTestProfile(created) + revision++ + }, + onRenameProfile = null, + onBindingsChanged = { + activity.reloadControllerTestBindings() + revision++ + }, + ) + } + } + } +} diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 70c8f1a4e..7ce9a5a8b 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -334,6 +334,10 @@ public void run() { }; private OnExtractFileListener onExtractFileListener; private WinHandler winHandler; + private com.winlator.cmod.runtime.input.controls.SteamControllerBackend steamControllerBackend; + private ComposeView controllerTestComposeView; + private final ExternalController controllerTestController = new ExternalController(); + private boolean controllerTestGuideDown = false; private WineRequestHandler wineRequestHandler; private float globalCursorSpeed = 1.0f; private MagnifierView magnifierView; @@ -4677,6 +4681,8 @@ private void uploadSavesIfAgentExitSyncLeftThemLocal(String trigger) { @Override protected void onDestroy() { activityDestroyed.set(true); + stopSteamControllerSupport(); + hideControllerTestDialog(); if (reshadeLiveHandler != null) { reshadeLiveHandler.removeCallbacks(reshadeLiveWriteTask); reshadeLiveHandler.post(reshadeLiveWriteTask); @@ -5827,6 +5833,12 @@ public void onInputControlsReverseBindingOrderChanged(boolean enabled) { renderDrawerMenu(); } + @Override + public void onControllerTestClick() { + if (drawerStateHolder != null) drawerStateHolder.closeDrawer(); + showControllerTestDialog(); + } + @Override public void onInputControlsEditClick() { ControlsProfile activeProfile = inputControlsView != null ? inputControlsView.getProfile() : null; @@ -8293,6 +8305,8 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { } winHandler.start(); + com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.setDialogOpen(false); + runOnUiThread(this::startSteamControllerSupport); if (wineRequestHandler != null) wineRequestHandler.start(); dxwrapperConfig = null; @@ -9537,6 +9551,12 @@ && isControllerMotionEvent(event)) { } return true; } + if (isSteamControllerShadowEvent(event.getDevice())) return true; + if (controllerTestComposeView != null + && com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.isActive() + && consumeControllerTestMotionEvent(event)) { + return true; + } if (isInputSuspended() && (drawerStateHolder == null || (!drawerStateHolder.isDrawerOpen() && !drawerStateHolder.isPaneOpen()))) { @@ -9629,6 +9649,12 @@ public boolean dispatchKeyEvent(KeyEvent event) { } return true; } + if (isSteamControllerShadowEvent(event.getDevice())) return true; + if (controllerTestComposeView != null + && com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.isActive() + && consumeControllerTestKeyEvent(event)) { + return true; + } if (isInputSuspended()) return super.dispatchKeyEvent(event); if (ExternalController.isGameController(event.getDevice())) { cancelMousePointerTimeout(); @@ -9690,6 +9716,175 @@ public boolean dispatchKeyEvent(KeyEvent event) { return super.dispatchKeyEvent(event); } + public InputControlsManager getControllerTestProfileManager() { + return inputControlsManager; + } + + public void applyControllerTestProfile(ControlsProfile profile) { + if (profile == null) return; + showInputControls(profile); + renderDrawerMenu(); + } + + public void reloadControllerTestBindings() { + if (inputControlsView == null) return; + ControlsProfile profile = inputControlsView.getProfile(); + if (profile != null) showInputControls(profile); + } + + private void showControllerTestDialog() { + if (controllerTestComposeView != null) return; + android.view.ViewGroup root = findViewById(android.R.id.content); + if (root == null) return; + ComposeView view = new ComposeView(this); + com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.onIdentify = + this::identifyControllerTestPad; + ControllerTestHost.attach(view, this, this::hideControllerTestDialog); + root.addView( + view, + new android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT)); + controllerTestComposeView = view; + } + + private void hideControllerTestDialog() { + com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.setDialogOpen(false); + com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.onIdentify = null; + if (controllerTestComposeView == null) return; + android.view.ViewParent parent = controllerTestComposeView.getParent(); + if (parent instanceof android.view.ViewGroup) { + ((android.view.ViewGroup) parent).removeView(controllerTestComposeView); + } + controllerTestComposeView = null; + } + + private void publishControllerTestSnapshot(android.view.InputDevice device) { + com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.publish( + controllerTestController, device, controllerTestGuideDown); + } + + private boolean consumeControllerTestKeyEvent(KeyEvent event) { + android.view.InputDevice device = event.getDevice(); + if (!ExternalController.isGameController(device)) return false; + if (event.getRepeatCount() == 0) { + if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_MODE) { + controllerTestGuideDown = event.getAction() == KeyEvent.ACTION_DOWN; + } + prepareControllerTestController(device); + controllerTestController.updateStateFromKeyEvent(event); + publishControllerTestSnapshot(device); + } + return true; + } + + private boolean consumeControllerTestMotionEvent(MotionEvent event) { + android.view.InputDevice device = event.getDevice(); + if (!ExternalController.isGameController(device)) return false; + prepareControllerTestController(device); + if (controllerTestController.updateStateFromMotionEvent(event)) { + publishControllerTestSnapshot(device); + } + return true; + } + + private void identifyControllerTestPad() { + int deviceId = com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.currentDeviceId(); + if (deviceId == Integer.MIN_VALUE) deviceId = controllerTestController.getDeviceId(); + if (steamControllerBackend != null + && deviceId <= com.winlator.cmod.runtime.input.controls.SteamControllerBackend.DEVICE_ID_BASE) { + steamControllerBackend.rumble(deviceId, 0xFFFF, 0xFFFF, 320); + return; + } + android.view.InputDevice device = android.view.InputDevice.getDevice(deviceId); + android.os.Vibrator vibrator = device != null ? device.getVibrator() : null; + if (vibrator == null || !vibrator.hasVibrator()) return; + vibrator.vibrate( + android.os.VibrationEffect.createOneShot( + 320L, android.os.VibrationEffect.DEFAULT_AMPLITUDE)); + } + + private void prepareControllerTestController(android.view.InputDevice device) { + if (device == null || controllerTestController.getDeviceId() == device.getId()) return; + controllerTestController.setDeviceId(device.getId()); + controllerTestController.setId(device.getDescriptor()); + controllerTestController.setName(device.getName()); + controllerTestController.setTriggerType(ExternalController.TRIGGER_IS_BOTH); + controllerTestGuideDown = false; + } + + private void startSteamControllerSupport() { + if (steamControllerBackend != null || winHandler == null) return; + if (isFinishing() || isDestroyed() || activityDestroyed.get()) return; + if (!com.winlator.cmod.runtime.input.controls.SteamControllerPrefs.isEnabled(this)) return; + int trackpadMode = + com.winlator.cmod.runtime.input.controls.SteamControllerPrefs.getTrackpadMouseMode(this); + com.winlator.cmod.runtime.input.controls.Binding[] paddles = + com.winlator.cmod.runtime.input.controls.SteamControllerPrefs.getPaddleBindings(this); + com.winlator.cmod.runtime.input.controls.SteamControllerBackend backend = + new com.winlator.cmod.runtime.input.controls.SteamControllerBackend( + this, trackpadMode, paddles, + new com.winlator.cmod.runtime.input.controls.SteamControllerBackend.Listener() { + @Override + public void onSteamPadConnected(ExternalController pad) { + if (winHandler != null) winHandler.onSdlPadConnected(pad); + } + + @Override + public void onSteamPadDisconnected(ExternalController pad) { + if (inputControlsView != null) inputControlsView.onSteamPadDisconnected(pad); + if (winHandler != null) winHandler.onSdlPadDisconnected(pad); + } + + @Override + public void onSteamPadState(ExternalController pad, boolean guideDown, + boolean quickAccessDown, int[] pressedKeyCodes) { + com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.publishSteamPad( + pad, guideDown, quickAccessDown); + if (controllerTestComposeView != null) return; + if (inputControlsView != null + && inputControlsView.onSteamPadState(pad, pressedKeyCodes)) return; + if (winHandler != null) winHandler.sendGamepadState(pad); + } + + @Override + public void onSteamPadBinding( + com.winlator.cmod.runtime.input.controls.Binding binding, boolean down) { + if (down && controllerTestComposeView != null) return; + if (inputControlsView != null) inputControlsView.handleInputEvent(binding, down); + } + + @Override + public void onSteamPadMouseMove(int dx, int dy) { + if (controllerTestComposeView != null) return; + if (winHandler != null) winHandler.steamPadMouseMove(dx, dy); + } + + @Override + public void onSteamPadMouseButton(boolean secondary, boolean down) { + if (down && controllerTestComposeView != null) return; + if (winHandler != null) winHandler.steamPadMouseButton(secondary, down); + } + }); + if (!backend.start()) return; + steamControllerBackend = backend; + winHandler.setSteamControllerBackend(backend); + } + + private void stopSteamControllerSupport() { + if (steamControllerBackend == null) return; + if (winHandler != null) winHandler.setSteamControllerBackend(null); + steamControllerBackend.stop(); + steamControllerBackend = null; + } + + private boolean isSteamControllerShadowEvent(android.view.InputDevice device) { + return steamControllerBackend != null && winHandler != null && winHandler.hasSdlPads() + && device != null + && device.getVendorId() + == com.winlator.cmod.runtime.input.controls.SteamControllerBackend.VALVE_VENDOR_ID; + } + public InputControlsView getInputControlsView() { return inputControlsView; } diff --git a/app/src/main/runtime/display/XServerDrawerControlsPane.kt b/app/src/main/runtime/display/XServerDrawerControlsPane.kt index ba40f688e..4ee2fccc2 100644 --- a/app/src/main/runtime/display/XServerDrawerControlsPane.kt +++ b/app/src/main/runtime/display/XServerDrawerControlsPane.kt @@ -217,6 +217,7 @@ internal fun InputControlsPaneContent( onProfileSelected = listener::onInputControlsProfileSelected, onEditClick = listener::onInputControlsEditClick, ) + ControllerTestRow(onClick = listener::onControllerTestClick) } if (state.inputControlsStyleNames.isNotEmpty()) { @@ -555,6 +556,39 @@ internal fun InputControlsProfileSelector( } } +@Composable +private fun ControllerTestRow(onClick: () -> Unit) { + val paneScale = LocalPaneScale.current + val cornerRadius = (12f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, shape) + .paneNavItem(cornerRadius = cornerRadius, onActivate = onClick) + .clickable(onClick = onClick) + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.SportsEsports, + contentDescription = null, + tint = DrawerTextPrimary, + modifier = Modifier.size((18f * paneScale).dp), + ) + Spacer(Modifier.width((10f * paneScale).dp)) + Text( + text = stringResource(R.string.controller_test_card_title), + color = DrawerTextPrimary, + fontSize = (13f * paneScale).sp, + fontWeight = FontWeight.Bold, + ) + } +} + // Drawer-styled dropdown for the Controls selectors. @Composable internal fun InputControlsOptionsPopup( diff --git a/app/src/main/runtime/display/XServerDrawerMenu.kt b/app/src/main/runtime/display/XServerDrawerMenu.kt index 410d760a8..4804aaa24 100644 --- a/app/src/main/runtime/display/XServerDrawerMenu.kt +++ b/app/src/main/runtime/display/XServerDrawerMenu.kt @@ -1162,6 +1162,8 @@ interface XServerDrawerActionListener { fun onInputControlsEditClick() + fun onControllerTestClick() + fun onScreenTouchModeChanged(mode: Int) fun onRtsGesturesToggled(enabled: Boolean) diff --git a/app/src/main/runtime/display/winhandler/WinHandler.java b/app/src/main/runtime/display/winhandler/WinHandler.java index 453c404c0..7ccf99cbf 100644 --- a/app/src/main/runtime/display/winhandler/WinHandler.java +++ b/app/src/main/runtime/display/winhandler/WinHandler.java @@ -24,6 +24,7 @@ import com.winlator.cmod.runtime.input.controls.ExternalController; import com.winlator.cmod.runtime.input.controls.FakeInputWriter; import com.winlator.cmod.runtime.input.controls.GamepadState; +import com.winlator.cmod.runtime.input.controls.SteamControllerBackend; import com.winlator.cmod.runtime.input.rumble.GamepadRumbleManager; import com.winlator.cmod.runtime.input.rumble.GcmRumbleMode; import com.winlator.cmod.shared.util.StringUtils; @@ -101,6 +102,9 @@ public class WinHandler { // ConcurrentHashMap: input thread mutates while the vibration thread iterates (avoid CME). private Map deviceToSlot = new java.util.concurrent.ConcurrentHashMap<>(); private Map descriptorToSlot = new HashMap<>(); // physical device → slot + private final Map sdlPads = + new java.util.concurrent.ConcurrentHashMap<>(); + private volatile SteamControllerBackend sdlBackend; private Map deviceToDescriptor = new HashMap<>(); // deviceId → descriptor private Set usedSlots = new HashSet(); private boolean xinputDisabledInitialized = false; @@ -252,8 +256,7 @@ private boolean assignConnectedDeviceIfPossible(int deviceId, String source) { // Still allow a reconnecting / sub-device that will reuse an existing // physical controller's slot instead of consuming a new one. Without this, // a transient remove/add while all four slots are full would be dropped. - android.view.InputDevice probe = android.view.InputDevice.getDevice(deviceId); - String descriptor = probe != null ? probe.getDescriptor() : null; + String descriptor = descriptorOf(deviceId); if (descriptor == null || !this.descriptorToSlot.containsKey(descriptor)) { Log.d( "WinHandler", @@ -262,9 +265,11 @@ private boolean assignConnectedDeviceIfPossible(int deviceId, String source) { } } - android.view.InputDevice device = android.view.InputDevice.getDevice(deviceId); - if (!ExternalController.isGameController(device)) { - return false; + if (!this.sdlPads.containsKey(deviceId)) { + android.view.InputDevice device = android.view.InputDevice.getDevice(deviceId); + if (!ExternalController.isGameController(device) || isShadowedBySdl(device)) { + return false; + } } ExternalController controller = getController(deviceId); @@ -806,9 +811,14 @@ private void writeControllerGamepadState( return; } ControlsProfile profile = this.activity.getInputControlsView().getProfile(); - if (profile != null - && (profileController = profile.getController(controller.getDeviceId())) != null - && profileController.getControllerBindingCount() > 0) { + boolean sdlPad = this.sdlPads.containsKey(controller.getDeviceId()); + profileController = + profile == null + ? null + : (sdlPad + ? profile.getController(controller.getId()) + : profile.getController(controller.getDeviceId())); + if (profileController != null && profileController.getControllerBindingCount() > 0) { int slot = assignSlot(controller.getDeviceId()); if (slot >= 0 && this.writers[slot] != null) { try { @@ -967,7 +977,7 @@ private void scheduleVirtualGamepadRebalance() { } private int assignSlot(int deviceId) { - if (deviceId != OSC_DEVICE_ID) { + if (deviceId != OSC_DEVICE_ID && !this.sdlPads.containsKey(deviceId)) { cancelPendingVirtualGamepadRebalance(); android.view.InputDevice physicalDevice = android.view.InputDevice.getDevice(deviceId); if (!ExternalController.isGameController(physicalDevice)) { @@ -997,11 +1007,7 @@ private int assignSlot(int deviceId) { // Resolve the physical device descriptor to group sub-devices (e.g. DualSense // gamepad + touchpad + motion sensors all share one physical controller) - String descriptor = null; - android.view.InputDevice device = android.view.InputDevice.getDevice(deviceId); - if (device != null) { - descriptor = device.getDescriptor(); - } + String descriptor = descriptorOf(deviceId); // If another deviceId from the same physical controller already has a slot, reuse it if (descriptor != null) { @@ -1219,6 +1225,16 @@ private void triggerVibration(int strong, int weak, int durationMs, int slot) { return; } + SteamControllerBackend backend = this.sdlBackend; + if (backend != null && !this.sdlPads.isEmpty()) { + for (Map.Entry entry : this.deviceToSlot.entrySet()) { + if (entry.getValue() == slot && this.sdlPads.containsKey(entry.getKey())) { + backend.rumble(entry.getKey(), strong, weak, durationMs); + return; + } + } + } + // GameSir GCM-mode pads expose no Android vibrator; route them through the GCM manager first. InputDevice physicalInputDevice = getPhysicalInputDeviceForSlot(slot); if (this.gcmRumbleMode != GcmRumbleMode.DISABLED @@ -1361,6 +1377,86 @@ public int getMaxControllers() { return MAX_CONTROLLERS; } + public void setSteamControllerBackend(SteamControllerBackend backend) { + this.sdlBackend = backend; + } + + public boolean hasSdlPads() { + return !this.sdlPads.isEmpty(); + } + + public void onSdlPadConnected(ExternalController pad) { + int deviceId = pad.getDeviceId(); + cancelPendingDeviceRelease(deviceId); + this.sdlPads.put(deviceId, pad); + releaseShadowedValveSlots(); + assignConnectedDeviceIfPossible(deviceId, "sdl"); + } + + public void onSdlPadDisconnected(ExternalController pad) { + int deviceId = pad.getDeviceId(); + if (!this.sdlPads.containsKey(deviceId)) { + return; + } + pad.state.reset(); + pad.remappedState.reset(); + if (this.deviceToSlot.containsKey(deviceId)) { + sendGamepadState(pad); + } + this.sdlPads.remove(deviceId); + scheduleDeviceRelease(deviceId); + } + + public void steamPadMouseMove(int dx, int dy) { + XServer xServer = this.activity != null ? this.activity.getXServer() : null; + if (xServer != null && !xServer.isRelativeMouseMovement()) { + xServer.injectPointerMoveDelta(dx, dy); + return; + } + mouseMoveDelta(dx, dy); + } + + public void steamPadMouseButton(boolean secondary, boolean down) { + XServer xServer = this.activity != null ? this.activity.getXServer() : null; + if (xServer == null) { + return; + } + Pointer.Button button = secondary ? Pointer.Button.BUTTON_RIGHT : Pointer.Button.BUTTON_LEFT; + if (xServer.isRelativeMouseMovement()) { + mouseEvent(MouseEventFlags.getFlagFor(button, down), 0, 0, 0); + } else if (down) { + xServer.injectPointerButtonPress(button); + } else { + xServer.injectPointerButtonRelease(button); + } + } + + private String descriptorOf(int deviceId) { + ExternalController sdlPad = this.sdlPads.get(deviceId); + if (sdlPad != null) { + return sdlPad.getId(); + } + android.view.InputDevice device = android.view.InputDevice.getDevice(deviceId); + return device != null ? device.getDescriptor() : null; + } + + private boolean isShadowedBySdl(android.view.InputDevice device) { + return device != null + && !this.sdlPads.isEmpty() + && device.getVendorId() == SteamControllerBackend.VALVE_VENDOR_ID; + } + + private void releaseShadowedValveSlots() { + for (Integer id : new ArrayList<>(this.deviceToSlot.keySet())) { + if (id == null || id < 0 || this.sdlPads.containsKey(id)) { + continue; + } + if (isShadowedBySdl(android.view.InputDevice.getDevice(id))) { + releaseSlot(id); + } + } + } + public void closeFakeInputWriter() { cancelPendingVirtualGamepadRebalance(); cancelAllPendingDeviceReleases(); @@ -1379,6 +1475,8 @@ public void closeFakeInputWriter() { this.deviceToDescriptor.clear(); this.usedSlots.clear(); this.controllers.clear(); + this.sdlPads.clear(); + this.sdlBackend = null; this.fallbackSlot = -1; this.vibrationRunning = false; if (this.vibrationServer != null) { @@ -1405,6 +1503,14 @@ public void closeFakeInputWriter() { } private ExternalController getController(int deviceId) { + ExternalController sdlPad = this.sdlPads.get(deviceId); + if (sdlPad != null) { + return sdlPad; + } + if (!this.sdlPads.isEmpty() + && isShadowedBySdl(android.view.InputDevice.getDevice(deviceId))) { + return null; + } if (this.controllers.containsKey(deviceId)) { return this.controllers.get(deviceId); } @@ -1771,6 +1877,9 @@ && getPreferredGyroController() != null) { private ExternalController getPreferredGyroController() { if (this.currentController != null) { int deviceId = this.currentController.getDeviceId(); + if (this.sdlPads.containsKey(deviceId)) { + return this.currentController; + } return deviceId >= 0 && android.view.InputDevice.getDevice(deviceId) != null ? this.currentController : null; diff --git a/app/src/main/runtime/input/controls/ExternalController.java b/app/src/main/runtime/input/controls/ExternalController.java index c4432eedc..f0e255639 100644 --- a/app/src/main/runtime/input/controls/ExternalController.java +++ b/app/src/main/runtime/input/controls/ExternalController.java @@ -182,6 +182,10 @@ public void setId(String id) { this.id = id; } + public void setDeviceId(int deviceId) { + this.deviceId = deviceId; + } + public byte getTriggerType() { return triggerType; } diff --git a/app/src/main/runtime/input/controls/GamepadState.java b/app/src/main/runtime/input/controls/GamepadState.java index 83f2ff198..73406569c 100644 --- a/app/src/main/runtime/input/controls/GamepadState.java +++ b/app/src/main/runtime/input/controls/GamepadState.java @@ -84,6 +84,17 @@ public byte getDPadY() { return (byte) (dpad[0] ? -1 : (dpad[2] ? 1 : 0)); } + public void reset() { + thumbLX = 0; + thumbLY = 0; + thumbRX = 0; + thumbRY = 0; + triggerL = 0; + triggerR = 0; + buttons = 0; + java.util.Arrays.fill(dpad, false); + } + public void copy(GamepadState other) { this.thumbLX = other.thumbLX; this.thumbLY = other.thumbLY; diff --git a/app/src/main/runtime/input/controls/SteamControllerBackend.java b/app/src/main/runtime/input/controls/SteamControllerBackend.java new file mode 100644 index 000000000..5a718c1d9 --- /dev/null +++ b/app/src/main/runtime/input/controls/SteamControllerBackend.java @@ -0,0 +1,558 @@ +package com.winlator.cmod.runtime.input.controls; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; +import android.os.SystemClock; +import android.util.Log; +import android.util.SparseArray; +import android.view.KeyEvent; +import java.util.Arrays; +import org.libsdl.app.HIDDeviceManager; +import org.libsdl.app.SDL; + +public final class SteamControllerBackend { + private static final String TAG = "SteamControllerBackend"; + + public static final int VALVE_VENDOR_ID = 0x28DE; + public static final int DEVICE_ID_BASE = -1000; + + private static final long POLL_INTERVAL_MS = 4; + private static final float TRACKPAD_PIXELS_PER_PAD = 900f; + private static final float TRIGGER_FULL = 0.98f; + + private static final int MAX_PADS = 4; + private static final int I_ID = 0, I_BUTTONS = 1, I_STRIDE = 2; + private static final int F_LX = 0, F_LY = 1, F_RX = 2, F_RY = 3, F_LT = 4, F_RT = 5; + private static final int F_RPAD_DOWN = 6, F_LPAD_DOWN = 9, F_STRIDE = 12; + private static final int B_A = 0, + B_B = 1, + B_X = 2, + B_Y = 3, + B_LB = 4, + B_RB = 5, + B_BACK = 6, + B_START = 7, + B_LSTICK = 8, + B_RSTICK = 9, + B_GUIDE = 10, + B_DPAD_UP = 11, + B_DPAD_DOWN = 12, + B_DPAD_LEFT = 13, + B_DPAD_RIGHT = 14, + B_QAM = 15, + B_R4 = 16, + B_L4 = 17, + B_R5 = 18, + B_L5 = 19, + B_RPAD_CLICK = 20, + B_LPAD_CLICK = 21; + + public static final int TRACKPAD_MOUSE_OFF = 0, + TRACKPAD_MOUSE_RIGHT = 1, + TRACKPAD_MOUSE_LEFT = 2, + TRACKPAD_MOUSE_BOTH = 3; + + public static final int PADDLE_COUNT = 5; + private static final int[] PADDLE_BITS = {B_L4, B_L5, B_R4, B_R5, B_QAM}; + + private static final int[][] BUTTON_KEYCODES = { + {B_A, KeyEvent.KEYCODE_BUTTON_A}, + {B_B, KeyEvent.KEYCODE_BUTTON_B}, + {B_X, KeyEvent.KEYCODE_BUTTON_X}, + {B_Y, KeyEvent.KEYCODE_BUTTON_Y}, + {B_LB, KeyEvent.KEYCODE_BUTTON_L1}, + {B_RB, KeyEvent.KEYCODE_BUTTON_R1}, + {B_BACK, KeyEvent.KEYCODE_BUTTON_SELECT}, + {B_START, KeyEvent.KEYCODE_BUTTON_START}, + {B_LSTICK, KeyEvent.KEYCODE_BUTTON_THUMBL}, + {B_RSTICK, KeyEvent.KEYCODE_BUTTON_THUMBR}, + {B_GUIDE, KeyEvent.KEYCODE_BUTTON_MODE}, + }; + + public interface Listener { + void onSteamPadConnected(ExternalController pad); + + void onSteamPadDisconnected(ExternalController pad); + + void onSteamPadState( + ExternalController pad, boolean guideDown, boolean quickAccessDown, int[] pressedKeyCodes); + + void onSteamPadBinding(Binding binding, boolean down); + + void onSteamPadMouseMove(int dx, int dy); + + void onSteamPadMouseButton(boolean secondary, boolean down); + } + + private static boolean librariesLoaded; + private static boolean jniReady; + private static SteamControllerBackend running_; + + private final Activity activity; + private final Listener listener; + private final int trackpadMode; + private final Binding[] paddleBindings = new Binding[PADDLE_COUNT]; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private HIDDeviceManager hidManager; + private Thread pollThread; + private volatile boolean running; + + private final Object frameLock = new Object(); + private int[] frameInts; + private float[] frameFloats; + private String[] frameNames; + private String[] framePaths; + private int frameCount; + private boolean applyQueued; + private final Runnable applyFrame = this::applyFrame; + + private final SparseArray pads = new SparseArray<>(); + + private static final class Pad { + final ExternalController controller; + int buttons = -1; + final float[] axes = new float[6]; + final Trackpad right = new Trackpad(); + final Trackpad left = new Trackpad(); + final boolean[] paddleDown = new boolean[PADDLE_COUNT]; + + Pad(ExternalController controller) { + this.controller = controller; + } + } + + private static final class Trackpad { + boolean down, clickDown; + float x, y, accX, accY; + } + + public SteamControllerBackend( + Activity activity, int trackpadMode, Binding[] paddles, Listener listener) { + this.activity = activity; + this.trackpadMode = + (trackpadMode < TRACKPAD_MOUSE_OFF || trackpadMode > TRACKPAD_MOUSE_BOTH) + ? TRACKPAD_MOUSE_RIGHT + : trackpadMode; + this.listener = listener; + for (int i = 0; i < PADDLE_COUNT; i++) { + Binding b = paddles != null && i < paddles.length ? paddles[i] : null; + paddleBindings[i] = b == null ? Binding.NONE : b; + } + } + + public static boolean hasBluetoothPermission(Context context) { + String permission = + Build.VERSION.SDK_INT >= 31 + ? Manifest.permission.BLUETOOTH_CONNECT + : Manifest.permission.BLUETOOTH; + return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED; + } + + public boolean start() { + if (running) return true; + if (running_ != null && running_ != this) running_.stop(); + if (!loadLibraries()) return false; + try { + if (!jniReady) { + SDL.setupJNI(); + jniReady = true; + } + SDL.initialize(); + SDL.setContext(activity); + hidManager = HIDDeviceManager.acquire(activity); + } catch (Throwable t) { + Log.e(TAG, "SDL Java setup failed; Steam Controller support stays off", t); + releaseHidManager(); + clearSdlContextIfUnowned(); + return false; + } + final boolean bluetooth = hasBluetoothPermission(activity); + running = true; + running_ = this; + pollThread = new Thread(() -> pollLoop(bluetooth), "SteamCtrlPoll"); + pollThread.start(); + Log.i(TAG, "Started (bluetooth " + bluetooth + ", trackpad mouse mode " + trackpadMode + ")"); + return true; + } + + public void stop() { + running = false; + boolean pollThreadFinished = true; + if (pollThread != null) { + try { + pollThread.join(1500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + pollThreadFinished = !pollThread.isAlive(); + pollThread = null; + } + if (running_ == this) running_ = null; + mainHandler.removeCallbacks(applyFrame); + synchronized (frameLock) { + applyQueued = false; + frameInts = null; + frameFloats = null; + frameCount = 0; + } + pads.clear(); + if (!pollThreadFinished) { + Log.w(TAG, "Poll thread did not finish in time; leaving SDL teardown to it"); + return; + } + releaseHidManager(); + clearSdlContextIfUnowned(); + Log.i(TAG, "Stopped"); + } + + private void releaseHidManager() { + if (hidManager == null) return; + try { + HIDDeviceManager.release(hidManager); + } catch (Throwable t) { + Log.e(TAG, "HIDDeviceManager.release failed", t); + } + hidManager = null; + } + + private static void clearSdlContextIfUnowned() { + if (running_ != null) return; + try { + SDL.setContext(null); + } catch (Throwable t) { + Log.e(TAG, "Clearing the SDL context failed", t); + } + } + + public void rumble(int deviceId, int low, int high, int durationMs) { + if (running) nativeRumble(DEVICE_ID_BASE - deviceId, low, high, durationMs); + } + + private static synchronized boolean loadLibraries() { + if (librariesLoaded) return true; + try { + System.loadLibrary("SDL3"); + System.loadLibrary("steamctrl"); + librariesLoaded = true; + } catch (Throwable t) { + Log.e(TAG, "Could not load SDL3 / steamctrl; Steam Controller support stays off", t); + } + return librariesLoaded; + } + + private void pollLoop(boolean bluetooth) { + try { + pollLoopInner(bluetooth); + } catch (Throwable t) { + Log.e(TAG, "Steam Controller poll thread failed; support stops this session", t); + running = false; + try { + nativeShutdown(); + } catch (Throwable shutdownFailure) { + Log.e(TAG, "nativeShutdown after poll failure also failed", shutdownFailure); + } + } + } + + private void pollLoopInner(boolean bluetooth) { + Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY); + if (!nativeInit(bluetooth)) { + Log.w(TAG, "SDL init failed; Steam Controller support stays off this session"); + running = false; + return; + } + int[] ints = new int[MAX_PADS * I_STRIDE]; + float[] floats = new float[MAX_PADS * F_STRIDE]; + String[] names = new String[MAX_PADS]; + String[] paths = new String[MAX_PADS]; + int[] lastInts = new int[ints.length]; + float[] lastFloats = new float[floats.length]; + int lastCount = 0; + SparseArray identities = new SparseArray<>(); + while (running) { + int count = nativePoll(ints, floats); + if (count < 0) break; + if (count != lastCount + || !Arrays.equals(ints, lastInts) + || !Arrays.equals(floats, lastFloats)) { + for (int p = 0; p < count; p++) { + int id = ints[p * I_STRIDE + I_ID]; + String[] identity = identities.get(id); + if (identity == null) { + identity = new String[] {nativeGetName(id), nativeGetPath(id)}; + identities.put(id, identity); + } + names[p] = identity[0]; + paths[p] = identity[1]; + } + publish(ints, floats, names, paths, count); + System.arraycopy(ints, 0, lastInts, 0, ints.length); + System.arraycopy(floats, 0, lastFloats, 0, floats.length); + lastCount = count; + } + SystemClock.sleep(POLL_INTERVAL_MS); + } + nativeShutdown(); + } + + private void publish(int[] ints, float[] floats, String[] names, String[] paths, int count) { + synchronized (frameLock) { + frameInts = ints.clone(); + frameFloats = floats.clone(); + frameNames = names.clone(); + framePaths = paths.clone(); + frameCount = count; + if (applyQueued) return; + applyQueued = true; + } + mainHandler.post(applyFrame); + } + + private void applyFrame() { + try { + applyFrameInner(); + } catch (Throwable t) { + Log.e(TAG, "Steam Controller frame delivery failed", t); + } + } + + private void applyFrameInner() { + int[] ints; + float[] floats; + String[] names; + String[] paths; + int count; + synchronized (frameLock) { + ints = frameInts; + floats = frameFloats; + names = frameNames; + paths = framePaths; + count = frameCount; + applyQueued = false; + } + if (!running || ints == null) return; + + for (int i = pads.size() - 1; i >= 0; i--) { + int id = pads.keyAt(i); + boolean present = false; + for (int p = 0; p < count; p++) { + if (ints[p * I_STRIDE + I_ID] == id) { + present = true; + break; + } + } + if (!present) { + Pad pad = pads.valueAt(i); + pads.removeAt(i); + releaseHeld(pad); + listener.onSteamPadDisconnected(pad.controller); + } + } + + for (int p = 0; p < count; p++) { + int id = ints[p * I_STRIDE + I_ID]; + Pad pad = pads.get(id); + if (pad == null) { + pad = new Pad(createController(id, names[p], paths[p])); + pads.put(id, pad); + listener.onSteamPadConnected(pad.controller); + } + applyPad(pad, ints[p * I_STRIDE + I_BUTTONS], floats, p * F_STRIDE); + } + } + + private static ExternalController createController(int sdlId, String name, String path) { + ExternalController controller = new ExternalController(); + if (name == null || name.isEmpty()) name = "Steam Controller"; + controller.setName(name); + controller.setId("sdl:" + (path != null && !path.isEmpty() ? path : name + "#" + sdlId)); + controller.setDeviceId(DEVICE_ID_BASE - sdlId); + return controller; + } + + private void applyPad(Pad pad, int buttons, float[] floats, int base) { + boolean changed = buttons != pad.buttons; + for (int a = 0; a < pad.axes.length; a++) { + if (pad.axes[a] != floats[base + a]) { + pad.axes[a] = floats[base + a]; + changed = true; + } + } + if (changed) { + pad.buttons = buttons; + int effective = buttons; + float lt = floats[base + F_LT]; + float rt = floats[base + F_RT]; + for (int i = 0; i < PADDLE_COUNT; i++) { + boolean down = bit(buttons, PADDLE_BITS[i]); + Binding target = paddleBindings[i]; + if (target == Binding.NONE) { + pad.paddleDown[i] = down; + continue; + } + if (target.isGamepad()) { + if (down) { + if (target == Binding.GAMEPAD_BUTTON_L2) lt = 1f; + else if (target == Binding.GAMEPAD_BUTTON_R2) rt = 1f; + else effective |= gamepadTargetBits(target); + } + } else if (down != pad.paddleDown[i]) { + listener.onSteamPadBinding(target, down); + } + pad.paddleDown[i] = down; + } + + GamepadState s = pad.controller.state; + s.thumbLX = deadZone(floats[base + F_LX]); + s.thumbLY = deadZone(floats[base + F_LY]); + s.thumbRX = deadZone(floats[base + F_RX]); + s.thumbRY = deadZone(floats[base + F_RY]); + s.triggerL = lt; + s.triggerR = rt; + s.setPressed(ExternalController.IDX_BUTTON_A, bit(effective, B_A)); + s.setPressed(ExternalController.IDX_BUTTON_B, bit(effective, B_B)); + s.setPressed(ExternalController.IDX_BUTTON_X, bit(effective, B_X)); + s.setPressed(ExternalController.IDX_BUTTON_Y, bit(effective, B_Y)); + s.setPressed(ExternalController.IDX_BUTTON_L1, bit(effective, B_LB)); + s.setPressed(ExternalController.IDX_BUTTON_R1, bit(effective, B_RB)); + s.setPressed(ExternalController.IDX_BUTTON_SELECT, bit(effective, B_BACK)); + s.setPressed(ExternalController.IDX_BUTTON_START, bit(effective, B_START)); + s.setPressed(ExternalController.IDX_BUTTON_L3, bit(effective, B_LSTICK)); + s.setPressed(ExternalController.IDX_BUTTON_R3, bit(effective, B_RSTICK)); + s.setPressed(ExternalController.IDX_BUTTON_L2, s.triggerL >= TRIGGER_FULL); + s.setPressed(ExternalController.IDX_BUTTON_R2, s.triggerR >= TRIGGER_FULL); + s.dpad[0] = bit(effective, B_DPAD_UP); + s.dpad[1] = bit(effective, B_DPAD_RIGHT); + s.dpad[2] = bit(effective, B_DPAD_DOWN); + s.dpad[3] = bit(effective, B_DPAD_LEFT); + listener.onSteamPadState( + pad.controller, bit(effective, B_GUIDE), bit(buttons, B_QAM), pressedKeyCodes(effective)); + } + if (trackpadMode == TRACKPAD_MOUSE_RIGHT || trackpadMode == TRACKPAD_MOUSE_BOTH) + applyTrackpad(pad.right, floats, base + F_RPAD_DOWN, bit(buttons, B_RPAD_CLICK), false); + if (trackpadMode == TRACKPAD_MOUSE_LEFT || trackpadMode == TRACKPAD_MOUSE_BOTH) + applyTrackpad( + pad.left, + floats, + base + F_LPAD_DOWN, + bit(buttons, B_LPAD_CLICK), + trackpadMode == TRACKPAD_MOUSE_BOTH); + } + + private static int gamepadTargetBits(Binding target) { + switch (target) { + case GAMEPAD_BUTTON_A: + return 1 << B_A; + case GAMEPAD_BUTTON_B: + return 1 << B_B; + case GAMEPAD_BUTTON_X: + return 1 << B_X; + case GAMEPAD_BUTTON_Y: + return 1 << B_Y; + case GAMEPAD_BUTTON_L1: + return 1 << B_LB; + case GAMEPAD_BUTTON_R1: + return 1 << B_RB; + case GAMEPAD_BUTTON_SELECT: + return 1 << B_BACK; + case GAMEPAD_BUTTON_START: + return 1 << B_START; + case GAMEPAD_BUTTON_L3: + return 1 << B_LSTICK; + case GAMEPAD_BUTTON_R3: + return 1 << B_RSTICK; + case GAMEPAD_DPAD_UP: + return 1 << B_DPAD_UP; + case GAMEPAD_DPAD_DOWN: + return 1 << B_DPAD_DOWN; + case GAMEPAD_DPAD_LEFT: + return 1 << B_DPAD_LEFT; + case GAMEPAD_DPAD_RIGHT: + return 1 << B_DPAD_RIGHT; + default: + return 0; + } + } + + private static int[] pressedKeyCodes(int buttons) { + int n = 0; + for (int[] m : BUTTON_KEYCODES) if (bit(buttons, m[0])) n++; + int[] out = new int[n]; + n = 0; + for (int[] m : BUTTON_KEYCODES) if (bit(buttons, m[0])) out[n++] = m[1]; + return out; + } + + private void applyTrackpad( + Trackpad t, float[] floats, int off, boolean click, boolean secondary) { + boolean down = floats[off] > 0.5f; + float x = floats[off + 1]; + float y = floats[off + 2]; + if (down && t.down) { + t.accX += (x - t.x) * TRACKPAD_PIXELS_PER_PAD; + t.accY += (y - t.y) * TRACKPAD_PIXELS_PER_PAD; + int dx = (int) t.accX; + int dy = (int) t.accY; + if (dx != 0 || dy != 0) { + t.accX -= dx; + t.accY -= dy; + listener.onSteamPadMouseMove(dx, dy); + } + } else { + t.accX = 0; + t.accY = 0; + } + t.down = down; + t.x = x; + t.y = y; + + if (click != t.clickDown) { + t.clickDown = click; + listener.onSteamPadMouseButton(secondary, click); + } + } + + private void releaseHeld(Pad pad) { + if (pad.right.clickDown) { + pad.right.clickDown = false; + listener.onSteamPadMouseButton(false, false); + } + if (pad.left.clickDown) { + pad.left.clickDown = false; + listener.onSteamPadMouseButton(trackpadMode == TRACKPAD_MOUSE_BOTH, false); + } + for (int i = 0; i < PADDLE_COUNT; i++) { + Binding target = paddleBindings[i]; + if (pad.paddleDown[i] && target != Binding.NONE && !target.isGamepad()) + listener.onSteamPadBinding(target, false); + pad.paddleDown[i] = false; + } + } + + private static boolean bit(int buttons, int b) { + return (buttons & (1 << b)) != 0; + } + + private static float deadZone(float v) { + return Math.abs(v) >= ControlElement.STICK_DEAD_ZONE ? v : 0.0f; + } + + private static native boolean nativeInit(boolean bluetooth); + + private static native int nativePoll(int[] ints, float[] floats); + + private static native String nativeGetName(int id); + + private static native String nativeGetPath(int id); + + private static native void nativeRumble(int id, int low, int high, int durationMs); + + private static native void nativeShutdown(); +} diff --git a/app/src/main/runtime/input/controls/SteamControllerPrefs.java b/app/src/main/runtime/input/controls/SteamControllerPrefs.java new file mode 100644 index 000000000..34592edc9 --- /dev/null +++ b/app/src/main/runtime/input/controls/SteamControllerPrefs.java @@ -0,0 +1,80 @@ +package com.winlator.cmod.runtime.input.controls; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.preference.PreferenceManager; + +public final class SteamControllerPrefs { + public static final String KEY_ENABLED = "steam_controller_sdl_enabled"; + public static final String KEY_TRACKPAD_MODE = "steam_controller_trackpad_mode"; + + private static final String[] KEY_PADDLES = { + "steam_controller_paddle_l4", + "steam_controller_paddle_l5", + "steam_controller_paddle_r4", + "steam_controller_paddle_r5", + "steam_controller_button_qam", + }; + + private SteamControllerPrefs() {} + + public static boolean isEnabled(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(KEY_ENABLED, false); + } + + public static void setEnabled(Context context, boolean enabled) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(KEY_ENABLED, enabled) + .apply(); + } + + public static int getTrackpadMouseMode(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + int mode = prefs.getInt(KEY_TRACKPAD_MODE, SteamControllerBackend.TRACKPAD_MOUSE_RIGHT); + if (mode < SteamControllerBackend.TRACKPAD_MOUSE_OFF + || mode > SteamControllerBackend.TRACKPAD_MOUSE_BOTH) { + return SteamControllerBackend.TRACKPAD_MOUSE_RIGHT; + } + return mode; + } + + public static void setTrackpadMouseMode(Context context, int mode) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putInt(KEY_TRACKPAD_MODE, mode) + .apply(); + } + + private static Binding parse(String name) { + if (name == null || name.isEmpty()) return Binding.NONE; + try { + return Binding.fromString(name); + } catch (IllegalArgumentException e) { + return Binding.NONE; + } + } + + public static Binding[] getPaddleBindings(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + Binding[] out = new Binding[KEY_PADDLES.length]; + for (int i = 0; i < KEY_PADDLES.length; i++) { + out[i] = parse(prefs.getString(KEY_PADDLES[i], null)); + } + return out; + } + + public static Binding getPaddleBinding(Context context, int index) { + if (index < 0 || index >= KEY_PADDLES.length) return Binding.NONE; + return parse( + PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_PADDLES[index], null)); + } + + public static void setPaddleBinding(Context context, int index, Binding binding) { + if (index < 0 || index >= KEY_PADDLES.length) return; + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putString(KEY_PADDLES[index], binding == null ? Binding.NONE.name() : binding.name()) + .apply(); + } +} diff --git a/app/src/main/runtime/input/ui/InputControlsView.java b/app/src/main/runtime/input/ui/InputControlsView.java index f958e70bc..4d0257f21 100644 --- a/app/src/main/runtime/input/ui/InputControlsView.java +++ b/app/src/main/runtime/input/ui/InputControlsView.java @@ -58,6 +58,7 @@ public class InputControlsView extends View { public static final String EXTRA_ADAPTIVE_JOYSTICKS = "adaptiveJoysticks"; private static final byte MOUSE_WHEEL_DELTA = 120; private boolean editMode = false; + private final HashMap> steamPadHeldKeys = new HashMap<>(); private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); private final ColorFilter colorFilter = @@ -1010,6 +1011,72 @@ public void invalidateControlElement(ControlElement element) { dirtyRect.bottom + padding); } + public boolean onSteamPadState(ExternalController sdlPad, int[] pressedKeyCodes) { + if (editMode || profile == null) { + onSteamPadDisconnected(sdlPad); + return false; + } + ExternalController controller = profile.getController(sdlPad.getId()); + if (controller == null || controller.getControllerBindingCount() == 0) { + onSteamPadDisconnected(sdlPad); + return false; + } + controller.setDeviceId(sdlPad.getDeviceId()); + controller.state.copy(sdlPad.state); + + Set held = steamPadHeldKeys.get(sdlPad.getId()); + if (held == null) { + held = new HashSet<>(); + steamPadHeldKeys.put(sdlPad.getId(), held); + } + Set pressed = new HashSet<>(); + for (int keyCode : pressedKeyCodes) pressed.add(keyCode); + + for (Integer keyCode : new HashSet<>(held)) { + if (!pressed.contains(keyCode)) { + held.remove(keyCode); + ExternalControllerBinding binding = controller.getControllerBinding(keyCode); + if (binding != null) handleInputEvent(controller, binding.getBinding(), false); + } + } + for (Integer keyCode : pressed) { + if (held.add(keyCode)) { + ExternalControllerBinding binding = controller.getControllerBinding(keyCode); + if (binding != null) handleInputEvent(controller, binding.getBinding(), true); + } + } + + ExternalControllerBinding triggerBinding = + controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_L2); + if (triggerBinding != null) { + handleInputEvent( + controller, + triggerBinding.getBinding(), + controller.state.isPressed(ExternalController.IDX_BUTTON_L2)); + } + triggerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_R2); + if (triggerBinding != null) { + handleInputEvent( + controller, + triggerBinding.getBinding(), + controller.state.isPressed(ExternalController.IDX_BUTTON_R2)); + } + + processJoystickInput(controller); + return true; + } + + public void onSteamPadDisconnected(ExternalController sdlPad) { + Set held = steamPadHeldKeys.remove(sdlPad.getId()); + if (held == null || held.isEmpty() || profile == null) return; + ExternalController controller = profile.getController(sdlPad.getId()); + if (controller == null) return; + for (Integer keyCode : held) { + ExternalControllerBinding binding = controller.getControllerBinding(keyCode); + if (binding != null) handleInputEvent(controller, binding.getBinding(), false); + } + } + public boolean onKeyEvent(KeyEvent event) { if (getContext() instanceof XServerDisplayActivity && ((XServerDisplayActivity)getContext()).isInputSuspended()) return false; if (profile != null && event.getRepeatCount() == 0) { diff --git a/app/src/main/shared/ui/controllertest/BindTargets.kt b/app/src/main/shared/ui/controllertest/BindTargets.kt new file mode 100644 index 000000000..98c11da7d --- /dev/null +++ b/app/src/main/shared/ui/controllertest/BindTargets.kt @@ -0,0 +1,258 @@ +package com.winlator.cmod.shared.ui.controllertest + +import android.content.Context +import android.view.KeyEvent +import com.winlator.cmod.R +import com.winlator.cmod.runtime.input.controls.Binding +import com.winlator.cmod.runtime.input.controls.ControlsProfile +import com.winlator.cmod.runtime.input.controls.ExternalController +import com.winlator.cmod.runtime.input.controls.ExternalControllerBinding + +internal data class BindTarget( + val id: String, + val nameRes: Int?, + val literalName: String?, + val keyCode: Int, + val native: Binding?, +) + +internal val BIND_TARGETS: List = + listOf( + BindTarget("a", null, "A", KeyEvent.KEYCODE_BUTTON_A, Binding.GAMEPAD_BUTTON_A), + BindTarget("b", null, "B", KeyEvent.KEYCODE_BUTTON_B, Binding.GAMEPAD_BUTTON_B), + BindTarget("x", null, "X", KeyEvent.KEYCODE_BUTTON_X, Binding.GAMEPAD_BUTTON_X), + BindTarget("y", null, "Y", KeyEvent.KEYCODE_BUTTON_Y, Binding.GAMEPAD_BUTTON_Y), + BindTarget("lb", null, "LB", KeyEvent.KEYCODE_BUTTON_L1, Binding.GAMEPAD_BUTTON_L1), + BindTarget("rb", null, "RB", KeyEvent.KEYCODE_BUTTON_R1, Binding.GAMEPAD_BUTTON_R1), + BindTarget("lt", null, "LT", KeyEvent.KEYCODE_BUTTON_L2, Binding.GAMEPAD_BUTTON_L2), + BindTarget("rt", null, "RT", KeyEvent.KEYCODE_BUTTON_R2, Binding.GAMEPAD_BUTTON_R2), + BindTarget( + "l3", + R.string.controller_bind_target_l3, + null, + KeyEvent.KEYCODE_BUTTON_THUMBL, + Binding.GAMEPAD_BUTTON_L3, + ), + BindTarget( + "r3", + R.string.controller_bind_target_r3, + null, + KeyEvent.KEYCODE_BUTTON_THUMBR, + Binding.GAMEPAD_BUTTON_R3, + ), + BindTarget( + "start", + R.string.controller_bind_target_start, + null, + KeyEvent.KEYCODE_BUTTON_START, + Binding.GAMEPAD_BUTTON_START, + ), + BindTarget( + "back", + R.string.controller_bind_target_back, + null, + KeyEvent.KEYCODE_BUTTON_SELECT, + Binding.GAMEPAD_BUTTON_SELECT, + ), + BindTarget("guide", R.string.controller_bind_target_guide, null, KeyEvent.KEYCODE_BUTTON_MODE, null), + BindTarget( + "dup", + R.string.controller_bind_target_dpad_up, + null, + KeyEvent.KEYCODE_DPAD_UP, + Binding.GAMEPAD_DPAD_UP, + ), + BindTarget( + "ddown", + R.string.controller_bind_target_dpad_down, + null, + KeyEvent.KEYCODE_DPAD_DOWN, + Binding.GAMEPAD_DPAD_DOWN, + ), + BindTarget( + "dleft", + R.string.controller_bind_target_dpad_left, + null, + KeyEvent.KEYCODE_DPAD_LEFT, + Binding.GAMEPAD_DPAD_LEFT, + ), + BindTarget( + "dright", + R.string.controller_bind_target_dpad_right, + null, + KeyEvent.KEYCODE_DPAD_RIGHT, + Binding.GAMEPAD_DPAD_RIGHT, + ), + BindTarget( + "lsu", + R.string.controller_bind_target_lstick_up, + null, + ExternalControllerBinding.AXIS_Y_NEGATIVE.toInt(), + Binding.GAMEPAD_LEFT_THUMB_UP, + ), + BindTarget( + "lsd", + R.string.controller_bind_target_lstick_down, + null, + ExternalControllerBinding.AXIS_Y_POSITIVE.toInt(), + Binding.GAMEPAD_LEFT_THUMB_DOWN, + ), + BindTarget( + "lsl", + R.string.controller_bind_target_lstick_left, + null, + ExternalControllerBinding.AXIS_X_NEGATIVE.toInt(), + Binding.GAMEPAD_LEFT_THUMB_LEFT, + ), + BindTarget( + "lsr", + R.string.controller_bind_target_lstick_right, + null, + ExternalControllerBinding.AXIS_X_POSITIVE.toInt(), + Binding.GAMEPAD_LEFT_THUMB_RIGHT, + ), + BindTarget( + "rsu", + R.string.controller_bind_target_rstick_up, + null, + ExternalControllerBinding.AXIS_RZ_NEGATIVE.toInt(), + Binding.GAMEPAD_RIGHT_THUMB_UP, + ), + BindTarget( + "rsd", + R.string.controller_bind_target_rstick_down, + null, + ExternalControllerBinding.AXIS_RZ_POSITIVE.toInt(), + Binding.GAMEPAD_RIGHT_THUMB_DOWN, + ), + BindTarget( + "rsl", + R.string.controller_bind_target_rstick_left, + null, + ExternalControllerBinding.AXIS_Z_NEGATIVE.toInt(), + Binding.GAMEPAD_RIGHT_THUMB_LEFT, + ), + BindTarget( + "rsr", + R.string.controller_bind_target_rstick_right, + null, + ExternalControllerBinding.AXIS_Z_POSITIVE.toInt(), + Binding.GAMEPAD_RIGHT_THUMB_RIGHT, + ), + ) + +internal val BIND_BY_ID: Map = BIND_TARGETS.associateBy { it.id } + +internal fun bindTargetName( + context: Context, + target: BindTarget, +): String = target.nameRes?.let { context.getString(it) } ?: target.literalName ?: target.id + +internal fun bindingLabelOrNull( + controller: ExternalController?, + id: String, +): String? { + val t = BIND_BY_ID[id] ?: return null + val b = controller?.getControllerBinding(t.keyCode) ?: return null + return b.binding?.toString() +} + +internal fun isBound( + controller: ExternalController?, + id: String, +): Boolean { + val t = BIND_BY_ID[id] ?: return false + return controller?.getControllerBinding(t.keyCode) != null +} + +internal fun boundCount(controller: ExternalController?): Int { + if (controller == null) return 0 + return BIND_TARGETS.count { controller.getControllerBinding(it.keyCode) != null } +} + +internal fun setTarget( + controller: ExternalController, + profile: ControlsProfile, + id: String, + binding: Binding, +) { + val t = BIND_BY_ID[id] ?: return + if (t.keyCode == KeyEvent.KEYCODE_UNKNOWN) return + val existing = controller.getControllerBinding(t.keyCode) + if (existing == null) { + val created = ExternalControllerBinding() + created.setKeyCode(t.keyCode) + created.binding = binding + controller.addControllerBinding(created) + } else { + existing.binding = binding + } + profile.save() +} + +internal fun clearTarget( + controller: ExternalController, + profile: ControlsProfile, + id: String, +) { + val t = BIND_BY_ID[id] ?: return + val b = controller.getControllerBinding(t.keyCode) ?: return + controller.removeControllerBinding(b) + profile.save() +} + +internal fun fillNative( + controller: ExternalController, + profile: ControlsProfile, +) { + var changed = false + for (t in BIND_TARGETS) { + if (t.native == null) continue + if (controller.getControllerBinding(t.keyCode) == null) { + val b = ExternalControllerBinding() + b.setKeyCode(t.keyCode) + b.binding = t.native + controller.addControllerBinding(b) + changed = true + } + } + if (changed) profile.save() +} + +internal enum class BindCategory { KEYBOARD, MOUSE, XBOX, NONE } + +internal fun categoryLabels(cat: BindCategory): Array = + when (cat) { + BindCategory.KEYBOARD -> Binding.keyboardBindingLabels() + BindCategory.MOUSE -> Binding.mouseBindingLabels() + BindCategory.XBOX -> Binding.gamepadBindingLabels() + BindCategory.NONE -> arrayOf(Binding.NONE.toString()) + } + +internal fun categoryValues(cat: BindCategory): Array = + when (cat) { + BindCategory.KEYBOARD -> Binding.keyboardBindingValues() + BindCategory.MOUSE -> Binding.mouseBindingValues() + BindCategory.XBOX -> Binding.gamepadBindingValues() + BindCategory.NONE -> arrayOf(Binding.NONE) + } + +internal fun resolveBindController( + profile: ControlsProfile, + controllerId: String?, + controllerName: String?, +): ExternalController? { + val id = + controllerId?.takeIf { it.isNotEmpty() } + ?: ExternalController.getControllers().firstOrNull()?.id + ?: return profile.loadControllers().firstOrNull { it != null } + profile.getController(id)?.let { return it } + val controller = + ExternalController.getController(id) + ?: ExternalController().apply { + setId(id) + name = controllerName?.takeIf { it.isNotEmpty() } ?: id + } + profile.putController(controller) + return controller +} diff --git a/app/src/main/shared/ui/controllertest/ControllerTestBus.kt b/app/src/main/shared/ui/controllertest/ControllerTestBus.kt new file mode 100644 index 000000000..4cc4f29ff --- /dev/null +++ b/app/src/main/shared/ui/controllertest/ControllerTestBus.kt @@ -0,0 +1,140 @@ +package com.winlator.cmod.shared.ui.controllertest + +import android.view.InputDevice +import com.winlator.cmod.runtime.input.controls.ExternalController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +data class ControllerTestSnapshot + @JvmOverloads + constructor( + val buttons: Int, + val dpadUp: Boolean, + val dpadRight: Boolean, + val dpadDown: Boolean, + val dpadLeft: Boolean, + val thumbLX: Float, + val thumbLY: Float, + val thumbRX: Float, + val thumbRY: Float, + val triggerL: Float, + val triggerR: Float, + val guide: Boolean, + val deviceId: Int, + val deviceName: String, + val deviceDescriptor: String, + val padArt: Int, + val batteryPct: Int, + val hasVibrator: Boolean, + val quickAccess: Boolean = false, + ) + +object ControllerTestBus { + private val _snapshot = MutableStateFlow(null) + val snapshot: StateFlow = _snapshot + + @Volatile + private var active: Boolean = false + + @Volatile + private var dialogOpen: Boolean = false + + @JvmField + @Volatile + var onIdentify: Runnable? = null + + @JvmStatic + fun isActive(): Boolean = active + + @JvmStatic + fun isDialogOpen(): Boolean = dialogOpen + + @JvmStatic + fun currentDeviceId(): Int = _snapshot.value?.deviceId ?: Int.MIN_VALUE + + @JvmStatic + fun setActive(value: Boolean) { + active = value + } + + @JvmStatic + fun setDialogOpen(value: Boolean) { + dialogOpen = value + if (!value) { + active = false + _snapshot.value = null + } + } + + private fun batteryPercent(device: InputDevice?): Int { + if (device == null || android.os.Build.VERSION.SDK_INT < 29) return -1 + val state = device.batteryState ?: return -1 + if (!state.isPresent) return -1 + val capacity = state.capacity + if (capacity.isNaN() || capacity < 0f) return -1 + return (capacity * 100f).toInt().coerceIn(0, 100) + } + + @JvmStatic + fun publish( + controller: ExternalController, + device: InputDevice?, + guideDown: Boolean, + ) { + if (!dialogOpen) return + val state = controller.state + _snapshot.value = + ControllerTestSnapshot( + buttons = state.buttons.toInt() and 0xFFFF, + dpadUp = state.dpad[0], + dpadRight = state.dpad[1], + dpadDown = state.dpad[2], + dpadLeft = state.dpad[3], + thumbLX = state.thumbLX, + thumbLY = state.thumbLY, + thumbRX = state.thumbRX, + thumbRY = state.thumbRY, + triggerL = state.triggerL, + triggerR = state.triggerR, + guide = guideDown, + deviceId = controller.deviceId, + deviceName = device?.name ?: controller.name ?: "", + deviceDescriptor = device?.descriptor ?: controller.id ?: "", + padArt = classifyPadArt(device).ordinal, + batteryPct = batteryPercent(device), + hasVibrator = device?.vibrator?.hasVibrator() == true, + ) + } + + @JvmStatic + fun publishSteamPad( + pad: ExternalController, + guideDown: Boolean, + quickAccessDown: Boolean, + ) { + if (!dialogOpen) return + val state = pad.state + _snapshot.value = + ControllerTestSnapshot( + buttons = state.buttons.toInt() and 0xFFFF, + dpadUp = state.dpad[0], + dpadRight = state.dpad[1], + dpadDown = state.dpad[2], + dpadLeft = state.dpad[3], + thumbLX = state.thumbLX, + thumbLY = state.thumbLY, + thumbRX = state.thumbRX, + thumbRY = state.thumbRY, + triggerL = state.triggerL, + triggerR = state.triggerR, + guide = guideDown, + deviceId = pad.deviceId, + deviceName = pad.name ?: "Steam Controller", + deviceDescriptor = pad.id ?: "", + padArt = PadArt.STEAM.ordinal, + batteryPct = -1, + hasVibrator = true, + quickAccess = quickAccessDown, + ) + } +} diff --git a/app/src/main/shared/ui/controllertest/ControllerTestDialog.kt b/app/src/main/shared/ui/controllertest/ControllerTestDialog.kt new file mode 100644 index 000000000..d4bf8a881 --- /dev/null +++ b/app/src/main/shared/ui/controllertest/ControllerTestDialog.kt @@ -0,0 +1,214 @@ +package com.winlator.cmod.shared.ui.controllertest + +import android.view.WindowManager +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import com.winlator.cmod.R +import com.winlator.cmod.runtime.input.controls.ControlsProfile +import com.winlator.cmod.runtime.input.controls.ExternalController +import com.winlator.cmod.shared.theme.WinNativeAccent +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import kotlin.math.min + +@Composable +fun ControllerTestDialog( + onDismiss: () -> Unit, + profile: ControlsProfile?, + allProfiles: List, + onSelectProfile: (ControlsProfile) -> Unit, + onCreateProfile: (String) -> Unit, + onRenameProfile: (() -> Unit)?, + onBindingsChanged: () -> Unit, + onOpenAllBindings: (() -> Unit)? = null, + onDeleteProfile: (() -> Unit)? = null, + startInBind: Boolean = false, +) { + val snap by ControllerTestBus.snapshot.collectAsState() + var manualArt by remember { mutableStateOf(null) } + var bindMode by remember { mutableStateOf(startInBind) } + val padName = remember { ExternalController.getControllers().firstOrNull()?.name } + val padDescriptor = remember { ExternalController.getControllers().firstOrNull()?.id } + + DisposableEffect(Unit) { + ControllerTestBus.setDialogOpen(true) + onDispose { + ControllerTestBus.setDialogOpen(false) + } + } + + BackHandler { onDismiss() } + + key(bindMode) { + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + val window = (LocalView.current.parent as? DialogWindowProvider)?.window + val testMode = !bindMode + SideEffect { + ControllerTestBus.setDialogOpen(true) + ControllerTestBus.setActive(testMode) + window?.apply { + if (testMode) { + addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + } else { + clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + } + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setDimAmount(0f) + } + } + + val cfg = LocalConfiguration.current + val w = min(760, cfg.screenWidthDp - 24).coerceAtLeast(300).dp + val maxH = (cfg.screenHeightDp - 24).coerceAtLeast(220).dp + val panelMax = (maxH.value - 76f).coerceAtLeast(180f).dp + + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { }, + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.padding(12.dp).width(w).heightIn(max = maxH), + shape = RoundedCornerShape(18.dp), + color = WinNativeSurface, + contentColor = WinNativeTextPrimary, + border = BorderStroke(1.dp, WinNativeOutline), + shadowElevation = 24.dp, + ) { + Column(Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + TestBindToggle(bindMode) { bindMode = it } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_ui_close)) + } + } + Spacer(Modifier.height(8.dp)) + if (bindMode) { + VisualControllerBinder( + profile = profile, + art = padArtOf(snap, padName), + snapshot = snap, + controllerId = snap?.deviceDescriptor?.takeIf { it.isNotEmpty() } ?: padDescriptor, + controllerName = snap?.deviceName?.takeIf { it.isNotEmpty() } ?: padName, + allProfiles = allProfiles, + onSelectProfile = onSelectProfile, + onCreateProfile = onCreateProfile, + onRenameProfile = onRenameProfile, + onSaved = onBindingsChanged, + onOpenAllBindings = onOpenAllBindings, + onDeleteProfile = onDeleteProfile, + modifier = Modifier.fillMaxWidth().heightIn(max = panelMax), + ) + } else { + ControllerTestPanel( + snapshot = snap, + title = stringResource(R.string.controller_test_title), + subtitle = snap?.deviceName?.takeIf { it.isNotEmpty() } ?: padName ?: "", + resetKey = Unit, + manualArt = manualArt, + onArtChange = { manualArt = it }, + identifyEnabled = snap?.hasVibrator == true, + onIdentify = { ControllerTestBus.onIdentify?.run() }, + onDone = null, + modifier = Modifier.fillMaxWidth().heightIn(max = panelMax), + nameHint = padName, + ) + } + } + } + } + } + } +} + +@Composable +private fun TestBindToggle( + bind: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + Modifier + .clip(RoundedCornerShape(10.dp)) + .border(1.dp, WinNativeOutline, RoundedCornerShape(10.dp)), + ) { + SegItem(stringResource(R.string.controller_test_mode_test), !bind) { onChange(false) } + SegItem(stringResource(R.string.controller_test_mode_bind), bind) { onChange(true) } + } +} + +@Composable +private fun SegItem( + label: String, + on: Boolean, + onClick: () -> Unit, +) { + Box( + Modifier + .background(if (on) WinNativeAccent else Color.Transparent) + .clickable { onClick() } + .padding(horizontal = 14.dp, vertical = 7.dp), + ) { + Text( + label, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = if (on) Color.White else WinNativeTextSecondary, + ) + } +} diff --git a/app/src/main/shared/ui/controllertest/ControllerTestVisualizer.kt b/app/src/main/shared/ui/controllertest/ControllerTestVisualizer.kt new file mode 100644 index 000000000..08323b645 --- /dev/null +++ b/app/src/main/shared/ui/controllertest/ControllerTestVisualizer.kt @@ -0,0 +1,673 @@ +package com.winlator.cmod.shared.ui.controllertest + +import android.content.res.Configuration +import android.view.InputDevice +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeAccent +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import java.util.Locale +import kotlin.math.roundToInt + +private val TestLive = Color(0xFF57C777) +private val TestTile = Color(0xFF14141E) +private val TestMenuCard = Color(0xFF1C1C2A) +private val TestNub = Color(0xFF12161D) +internal val BIND_BLUE = Color(0xFF7AA0FF) + +enum class PadArt { + XBOX_360, + XBOX_MODERN, + DUALSENSE, + DUALSHOCK4, + SWITCH_PRO, + EIGHTBITDO, + GENERIC, + DUALSHOCK3, + STEAM, + GAMECUBE, + SNES, +} + +fun padArtLabel(a: PadArt): String = + when (a) { + PadArt.XBOX_360 -> "Xbox 360" + PadArt.XBOX_MODERN -> "Xbox" + PadArt.DUALSENSE -> "DualSense" + PadArt.DUALSHOCK4 -> "DualShock 4" + PadArt.SWITCH_PRO -> "Switch Pro" + PadArt.EIGHTBITDO -> "8BitDo" + PadArt.GENERIC -> "Generic" + PadArt.DUALSHOCK3 -> "DualShock 3" + PadArt.STEAM -> "Steam" + PadArt.GAMECUBE -> "GameCube" + PadArt.SNES -> "SNES" + } + +private fun padArtSafe(ordinal: Int): PadArt = PadArt.entries.getOrElse(ordinal) { PadArt.GENERIC } + +internal fun padArtOf( + snapshot: ControllerTestSnapshot?, + nameHint: String?, +): PadArt = + when { + snapshot != null -> padArtSafe(snapshot.padArt) + nameHint != null -> classifyPadArtByName(nameHint) + else -> PadArt.GENERIC + } + +fun classifyPadArt(device: InputDevice?): PadArt { + if (device == null) return PadArt.GENERIC + val n = (device.name ?: "").lowercase(Locale.US) + if (n.contains("8bitdo") || n.contains("sn30")) return PadArt.EIGHTBITDO + val p = device.productId + return when (device.vendorId) { + 0x045E -> if (n.contains("360")) PadArt.XBOX_360 else PadArt.XBOX_MODERN + 0x054C -> + when { + n.contains("dualsense") || p == 0x0CE6 || p == 0x0DF2 -> PadArt.DUALSENSE + n.contains("dualshock 4") || n.contains("wireless controller") || + p == 0x05C4 || p == 0x09CC || p == 0x0BA0 -> PadArt.DUALSHOCK4 + n.contains("motion") || p == 0x0268 -> PadArt.DUALSHOCK3 + else -> PadArt.DUALSHOCK4 + } + 0x057E -> PadArt.SWITCH_PRO + 0x2DC8 -> PadArt.EIGHTBITDO + 0x28DE -> PadArt.STEAM + else -> classifyPadArtByName(n) + } +} + +private fun classifyPadArtByName(nameRaw: String): PadArt { + val n = nameRaw.lowercase(Locale.US) + return when { + n.contains("8bitdo") || n.contains("sn30") || n.contains("pro 2") -> PadArt.EIGHTBITDO + n.contains("dualsense") -> PadArt.DUALSENSE + n.contains("dualshock 4") || n.contains("dualshock4") -> PadArt.DUALSHOCK4 + n.contains("dualshock 3") || n.contains("dualshock3") || n.contains("motion") -> PadArt.DUALSHOCK3 + n.contains("dualshock") || n.contains("playstation") -> PadArt.DUALSHOCK4 + n.contains("xbox") -> if (n.contains("360")) PadArt.XBOX_360 else PadArt.XBOX_MODERN + n.contains("switch") || n.contains("joy-con") || n.contains("pro controller") -> PadArt.SWITCH_PRO + n.contains("steam") -> PadArt.STEAM + else -> PadArt.GENERIC + } +} + +private val ALL_ELEMENTS = + listOf( + "a", "b", "x", "y", "lb", "rb", "lt", "rt", + "back", "start", "l3", "r3", "dup", "ddown", "dleft", "dright", "guide", + ) + +private val STEAM_ELEMENTS = ALL_ELEMENTS + "qam" + +@Composable +private fun friendlyName(id: String): String = + when (id) { + "a" -> "A / ✕" + "b" -> "B / ○" + "x" -> "X / □" + "y" -> "Y / △" + "lb" -> "LB / L1" + "rb" -> "RB / R1" + "lt" -> "LT / L2" + "rt" -> "RT / R2" + "back" -> stringResource(R.string.controller_test_element_back) + "start" -> stringResource(R.string.controller_test_element_start) + "l3" -> stringResource(R.string.controller_test_element_l3) + "r3" -> stringResource(R.string.controller_test_element_r3) + "dup" -> stringResource(R.string.controller_test_element_dpad_up) + "ddown" -> stringResource(R.string.controller_test_element_dpad_down) + "dleft" -> stringResource(R.string.controller_test_element_dpad_left) + "dright" -> stringResource(R.string.controller_test_element_dpad_right) + "guide" -> stringResource(R.string.controller_test_element_guide) + "qam" -> stringResource(R.string.controller_test_element_quick_access) + else -> id + } + +internal fun pressedSet(snap: ControllerTestSnapshot?): Set { + if (snap == null) return emptySet() + val s = HashSet() + fun bit(i: Int) = (snap.buttons and (1 shl i)) != 0 + if (bit(0)) s.add("a") + if (bit(1)) s.add("b") + if (bit(2)) s.add("x") + if (bit(3)) s.add("y") + if (bit(4)) s.add("lb") + if (bit(5)) s.add("rb") + if (bit(6)) s.add("back") + if (bit(7)) s.add("start") + if (bit(8)) s.add("l3") + if (bit(9)) s.add("r3") + if (bit(10) || snap.triggerL > 0.5f) s.add("lt") + if (bit(11) || snap.triggerR > 0.5f) s.add("rt") + if (snap.dpadUp) s.add("dup") + if (snap.dpadDown) s.add("ddown") + if (snap.dpadLeft) s.add("dleft") + if (snap.dpadRight) s.add("dright") + if (snap.guide) s.add("guide") + if (snap.quickAccess) s.add("qam") + return s +} + +private fun fmt2(v: Float): String { + val r = (v * 100).roundToInt() / 100f + return String.format(Locale.US, "%.2f", r) +} + +private fun pct(v: Float): String = "${(v.coerceIn(0f, 1f) * 100).roundToInt()}%" + +@Composable +internal fun Modifier.testMenuCard(): Modifier { + val shape = RoundedCornerShape(10.dp) + return this + .clip(shape) + .background(TestMenuCard) + .border(1.dp, WinNativeOutline, shape) +} + +@Composable +internal fun TestMenuDivider() { + HorizontalDivider(color = WinNativeOutline.copy(alpha = 0.5f)) +} + +@Composable +fun ControllerTestPanel( + snapshot: ControllerTestSnapshot?, + title: String, + subtitle: String, + resetKey: Any?, + manualArt: PadArt?, + onArtChange: (PadArt?) -> Unit, + identifyEnabled: Boolean, + onIdentify: (() -> Unit)?, + onDone: (() -> Unit)?, + modifier: Modifier = Modifier, + nameHint: String? = null, +) { + val autoArt = + when { + snapshot != null -> padArtSafe(snapshot.padArt) + nameHint != null -> classifyPadArtByName(nameHint) + else -> PadArt.GENERIC + } + val art = manualArt ?: autoArt + + val pressed = pressedSet(snapshot) + val elements = if (autoArt == PadArt.STEAM) STEAM_ELEMENTS else ALL_ELEMENTS + val seen = remember(resetKey) { mutableStateListOf() } + var lastInputId by remember(resetKey) { mutableStateOf(null) } + LaunchedEffect(pressed) { + pressed.forEach { if (it !in seen && it in elements) seen.add(it) } + pressed.firstOrNull { it in elements }?.let { lastInputId = it } + } + + Column(modifier) { + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + Column( + Modifier + .weight(1f, fill = false) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = WinNativeTextPrimary) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = WinNativeTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + ArtSelector(art, manualArt, onArtChange) + } + Spacer(Modifier.height(6.dp)) + + val live = snapshot != null + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + Modifier + .size(8.dp) + .clip(RoundedCornerShape(50)) + .background(if (live) TestLive else WinNativeTextSecondary), + ) + Spacer(Modifier.width(6.dp)) + Text( + if (live) { + stringResource(R.string.controller_test_live_input) + } else { + stringResource(R.string.controller_test_waiting) + }, + fontSize = 12.sp, + color = if (live) TestLive else WinNativeTextSecondary, + modifier = Modifier.weight(1f), + ) + val batt = snapshot?.batteryPct ?: -1 + if (batt in 0..100) { + Text( + stringResource(R.string.controller_test_battery, batt), + fontSize = 11.sp, + color = WinNativeTextSecondary, + ) + } + } + Spacer(Modifier.height(8.dp)) + + val ls = snapshot?.let { fmt2(it.thumbLX) + ", " + fmt2(it.thumbLY) } ?: "0.00, 0.00" + val rs = snapshot?.let { fmt2(it.thumbRX) + ", " + fmt2(it.thumbRY) } ?: "0.00, 0.00" + val tr = snapshot?.let { pct(it.triggerL) + " · " + pct(it.triggerR) } ?: "0% · 0%" + val lastInput = lastInputId?.let { friendlyName(it) } ?: "—" + val readouts = + listOf( + stringResource(R.string.controller_test_last_input) to lastInput, + stringResource(R.string.controller_test_left_stick) to ls, + stringResource(R.string.controller_test_right_stick) to rs, + stringResource(R.string.controller_test_triggers) to tr, + ) + if (landscape) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Box(Modifier.weight(0.52f), contentAlignment = Alignment.Center) { + PadArtView(art = art, snapshot = snapshot) + } + Column(Modifier.weight(0.48f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (pair in readouts.chunked(2)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + for ((k, v) in pair) StatTile(k, v, Modifier.weight(1f)) + if (pair.size == 1) Spacer(Modifier.weight(1f)) + } + } + } + } + } else { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + PadArtView(art = art, snapshot = snapshot) + } + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + for ((k, v) in readouts) StatTile(k, v, Modifier.weight(1f)) + } + } + Spacer(Modifier.height(10.dp)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + val done = seen.count { it in elements } + Text( + if (done >= elements.size) { + stringResource(R.string.controller_test_all_verified, elements.size) + } else { + stringResource(R.string.controller_test_verified_count, done, elements.size) + }, + fontSize = 12.5.sp, + fontWeight = FontWeight.SemiBold, + color = if (done >= elements.size) TestLive else WinNativeTextSecondary, + modifier = Modifier.weight(1f), + ) + if (onIdentify != null) { + Button( + onClick = { onIdentify() }, + enabled = identifyEnabled, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors(containerColor = WinNativeAccent), + ) { Text(stringResource(R.string.controller_test_identify), fontSize = 12.sp) } + } + if (onDone != null) { + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { onDone() }) { + Text(stringResource(R.string.common_ui_done)) + } + } + } + } +} + +@Composable +private fun ArtSelector( + effective: PadArt, + manual: PadArt?, + onChange: (PadArt?) -> Unit, +) { + var open by remember { mutableStateOf(false) } + val autoPrefix = stringResource(R.string.controller_test_art_auto_prefix) + Box { + OutlinedButton( + onClick = { open = true }, + shape = RoundedCornerShape(10.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + (if (manual == null) "$autoPrefix · " else "") + padArtLabel(effective), + fontSize = 12.sp, + ) + } + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + modifier = Modifier.testMenuCard(), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.controller_test_art_auto_detect)) }, + onClick = { + open = false + onChange(null) + }, + ) + for (a in PadArt.entries) { + TestMenuDivider() + DropdownMenuItem( + text = { Text(padArtLabel(a)) }, + onClick = { + open = false + onChange(a) + }, + ) + } + } + } +} + +@Composable +private fun StatTile( + key: String, + value: String, + modifier: Modifier, +) { + Column( + modifier + .clip(RoundedCornerShape(10.dp)) + .background(TestTile) + .border(1.dp, WinNativeOutline, RoundedCornerShape(10.dp)) + .padding(horizontal = 9.dp, vertical = 7.dp), + ) { + Text(key.uppercase(), fontSize = 9.sp, color = WinNativeTextSecondary) + Spacer(Modifier.height(2.dp)) + Text( + value, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = WinNativeTextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun highlightManifest( + ref: PadArtRef, + logical: String, +): String? { + val cands = + when (logical) { + "a", "b", "x", "y", "lt", "rt", "l3", "r3" -> listOf(logical) + "lb" -> listOf("lb", "l") + "rb" -> listOf("rb", "r") + "dup" -> listOf("dpad_up") + "ddown" -> listOf("dpad_down") + "dleft" -> listOf("dpad_left") + "dright" -> listOf("dpad_right") + "back" -> listOf("back", "select", "share", "minus", "view") + "start" -> listOf("start", "options", "plus", "menu") + "guide" -> listOf("guide", "home") + "qam" -> listOf("qam") + else -> emptyList() + } + return cands.firstOrNull { ref.el.containsKey(it) } +} + +internal fun manifestToBindId(manifestId: String): String? = + when (manifestId) { + "a", "b", "x", "y", "lt", "rt", "l3", "r3", "lb", "rb", "start", "back", "guide" -> manifestId + "dpad_up" -> "dup" + "dpad_down" -> "ddown" + "dpad_left" -> "dleft" + "dpad_right" -> "dright" + "l" -> "lb" + "r" -> "rb" + "lstick" -> "l3" + "rstick" -> "r3" + "cstick" -> "r3" + "home" -> "guide" + "options" -> "start" + "plus" -> "start" + "menu" -> "start" + "select" -> "back" + "share" -> "back" + "minus" -> "back" + "view" -> "back" + else -> null + } + +private fun bindToManifest( + ref: PadArtRef, + bindId: String, +): String? { + val cands = + when (bindId) { + "a", "b", "x", "y", "lt", "rt", "l3", "r3" -> listOf(bindId) + "lb" -> listOf("lb", "l") + "rb" -> listOf("rb", "r") + "dup" -> listOf("dpad_up") + "ddown" -> listOf("dpad_down") + "dleft" -> listOf("dpad_left") + "dright" -> listOf("dpad_right") + "back" -> listOf("back", "select", "share", "minus", "view") + "start" -> listOf("start", "options", "plus", "menu") + "guide" -> listOf("guide", "home") + "lsu", "lsd", "lsl", "lsr" -> listOf("lstick") + "rsu", "rsd", "rsl", "rsr" -> listOf("rstick", "cstick") + else -> emptyList() + } + return cands.firstOrNull { ref.el.containsKey(it) } +} + +@Composable +internal fun PadArtView( + art: PadArt, + snapshot: ControllerTestSnapshot?, + modifier: Modifier = Modifier, + boundBindIds: Set = emptySet(), + selectedBindId: String? = null, + onTapBind: ((String) -> Unit)? = null, +) { + val ref = PAD_REFS[art] ?: PAD_REFS[PadArt.GENERIC]!! + val accent = WinNativeAccent + val pressedManifest = + remember(snapshot, art) { + val out = HashSet() + for (p in pressedSet(snapshot)) highlightManifest(ref, p)?.let { out.add(it) } + out + } + val boundManifest = + remember(boundBindIds, art) { + val out = HashSet() + for (bid in boundBindIds) bindToManifest(ref, bid)?.let { out.add(it) } + out + } + val selectedManifest = selectedBindId?.let { bindToManifest(ref, it) } + + Box( + modifier + .widthIn(max = 248.dp) + .heightIn(max = 136.dp) + .aspectRatio(ref.vbW / ref.vbH), + ) { + Image( + painter = painterResource(ref.drawableRes), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Fit, + ) + Canvas( + Modifier.matchParentSize().then( + if (onTapBind != null) { + Modifier.pointerInput(art) { + detectTapGestures { pos -> + val sx = size.width / ref.vbW + val sy = size.height / ref.vbH + val sr = maxOf(sx, sy) + var best: String? = null + var bestD = Float.MAX_VALUE + for ((id, t) in ref.el) { + val cx = t.first * sx + val cy = t.second * sy + val rr = t.third * sr + val dx = pos.x - cx + val dy = pos.y - cy + val d = dx * dx + dy * dy + if (d <= rr * rr && d < bestD) { + bestD = d + best = id + } + } + best?.let { manifestToBindId(it) }?.let { onTapBind(it) } + } + } + } else { + Modifier + }, + ), + ) { + val sx = size.width / ref.vbW + val sy = size.height / ref.vbH + val sr = maxOf(sx, sy) + for (mid in boundManifest) { + val t = ref.el[mid] ?: continue + drawCircle( + BIND_BLUE, + 4f, + Offset(t.first * sx + t.third * sr * 0.6f, t.second * sy - t.third * sr * 0.6f), + ) + } + for (mid in pressedManifest) { + val t = ref.el[mid] ?: continue + val rr = t.third * sr + drawCircle(accent.copy(alpha = 0.30f), rr * 1.5f, Offset(t.first * sx, t.second * sy)) + drawCircle(accent, rr, Offset(t.first * sx, t.second * sy), style = Stroke(width = 2f)) + } + selectedManifest?.let { mid -> + val t = ref.el[mid] + if (t != null) { + drawCircle( + accent, + t.third * sr * 1.25f, + Offset(t.first * sx, t.second * sy), + style = Stroke(width = 2.5f), + ) + } + } + snapshot?.let { s -> + drawStickNub(ref, "lstick", s.thumbLX, s.thumbLY, sx, sy, sr, accent, "l3" in pressedManifest) + val rNub = if (ref.el.containsKey("rstick")) "rstick" else "cstick" + drawStickNub(ref, rNub, s.thumbRX, s.thumbRY, sx, sy, sr, accent, "r3" in pressedManifest) + } + } + } +} + +private fun DrawScope.drawStickNub( + ref: PadArtRef, + id: String, + tx: Float, + ty: Float, + sx: Float, + sy: Float, + sr: Float, + accent: Color, + clicked: Boolean, +) { + val t = ref.el[id] ?: return + val socketR = t.third * sr + val nubR = socketR * 0.55f + val travel = socketR * 0.45f + var ox = tx * travel + var oy = ty * travel + val maxOff = socketR - nubR + val mag = kotlin.math.sqrt((ox * ox + oy * oy).toDouble()).toFloat() + if (mag > maxOff && mag > 0f) { + val k = maxOff / mag + ox *= k + oy *= k + } + val cx = t.first * sx + ox + val cy = t.second * sy + oy + + val defl = kotlin.math.min(1f, kotlin.math.sqrt((tx * tx + ty * ty).toDouble()).toFloat()) + val glow = if (clicked) 1f else if (defl > 0.08f) defl else 0f + if (glow > 0f) { + drawCircle(accent.copy(alpha = 0.30f * glow), nubR * 1.5f, Offset(cx, cy)) + } + val fill = if (glow > 0f) lerp(TestNub, accent, 0.65f * glow) else TestNub + drawCircle(fill, nubR, Offset(cx, cy)) + if (glow > 0f) { + drawCircle(accent.copy(alpha = glow), nubR, Offset(cx, cy), style = Stroke(width = 2f)) + } + drawCircle(Color(0x22FFFFFF), nubR, Offset(cx, cy - nubR * 0.12f), style = Stroke(width = 1.2f)) +} diff --git a/app/src/main/shared/ui/controllertest/PadArtRefs.kt b/app/src/main/shared/ui/controllertest/PadArtRefs.kt new file mode 100644 index 000000000..84e5ce4de --- /dev/null +++ b/app/src/main/shared/ui/controllertest/PadArtRefs.kt @@ -0,0 +1,281 @@ +package com.winlator.cmod.shared.ui.controllertest + +import com.winlator.cmod.R + +internal class PadArtRef( + val drawableRes: Int, + val vbW: Float, + val vbH: Float, + val el: Map>, +) + +internal val PAD_REFS: Map = mapOf( + PadArt.XBOX_360 to PadArtRef( + drawableRes = R.drawable.pad_xbox360, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(372.0f, 170.0f, 13.0f), + "b" to Triple(400.0f, 142.0f, 13.0f), + "back" to Triple(212.0f, 132.0f, 9.0f), + "dpad_down" to Triple(197.0f, 228.0f, 11.0f), + "dpad_left" to Triple(179.0f, 210.0f, 11.0f), + "dpad_right" to Triple(215.0f, 210.0f, 11.0f), + "dpad_up" to Triple(197.0f, 192.0f, 11.0f), + "guide" to Triple(250.0f, 120.0f, 16.0f), + "l3" to Triple(150.0f, 155.0f, 24.0f), + "lb" to Triple(161.0f, 71.0f, 16.0f), + "lstick" to Triple(150.0f, 155.0f, 24.0f), + "lt" to Triple(161.0f, 53.0f, 12.0f), + "r3" to Triple(318.0f, 215.0f, 24.0f), + "rb" to Triple(339.0f, 71.0f, 16.0f), + "rstick" to Triple(318.0f, 215.0f, 24.0f), + "rt" to Triple(339.0f, 53.0f, 12.0f), + "start" to Triple(288.0f, 132.0f, 9.0f), + "x" to Triple(344.0f, 142.0f, 13.0f), + "y" to Triple(372.0f, 114.0f, 13.0f), + ), + ), + PadArt.XBOX_MODERN to PadArtRef( + drawableRes = R.drawable.pad_xbox_modern, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(372.0f, 170.0f, 13.0f), + "b" to Triple(400.0f, 142.0f, 13.0f), + "back" to Triple(214.0f, 140.0f, 8.0f), + "dpad_down" to Triple(197.0f, 228.0f, 11.0f), + "dpad_left" to Triple(179.0f, 210.0f, 11.0f), + "dpad_right" to Triple(215.0f, 210.0f, 11.0f), + "dpad_up" to Triple(197.0f, 192.0f, 11.0f), + "guide" to Triple(250.0f, 106.0f, 17.0f), + "l3" to Triple(150.0f, 155.0f, 24.0f), + "lb" to Triple(161.0f, 71.0f, 16.0f), + "lstick" to Triple(150.0f, 155.0f, 24.0f), + "lt" to Triple(161.0f, 53.0f, 12.0f), + "menu" to Triple(286.0f, 140.0f, 8.0f), + "r3" to Triple(318.0f, 215.0f, 24.0f), + "rb" to Triple(339.0f, 71.0f, 16.0f), + "rstick" to Triple(318.0f, 215.0f, 24.0f), + "rt" to Triple(339.0f, 53.0f, 12.0f), + "start" to Triple(286.0f, 140.0f, 8.0f), + "view" to Triple(214.0f, 140.0f, 8.0f), + "x" to Triple(344.0f, 142.0f, 13.0f), + "y" to Triple(372.0f, 114.0f, 13.0f), + ), + ), + PadArt.DUALSENSE to PadArtRef( + drawableRes = R.drawable.pad_dualsense, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(358.0f, 166.0f, 12.0f), + "b" to Triple(382.0f, 142.0f, 12.0f), + "back" to Triple(205.0f, 98.0f, 8.0f), + "dpad_down" to Triple(142.0f, 160.0f, 10.0f), + "dpad_left" to Triple(124.0f, 142.0f, 10.0f), + "dpad_right" to Triple(160.0f, 142.0f, 10.0f), + "dpad_up" to Triple(142.0f, 124.0f, 10.0f), + "guide" to Triple(250.0f, 197.0f, 11.0f), + "l3" to Triple(195.0f, 212.0f, 21.0f), + "lb" to Triple(162.0f, 72.0f, 15.0f), + "lstick" to Triple(195.0f, 212.0f, 21.0f), + "lt" to Triple(163.0f, 55.0f, 12.0f), + "r3" to Triple(305.0f, 212.0f, 21.0f), + "rb" to Triple(338.0f, 72.0f, 15.0f), + "rstick" to Triple(305.0f, 212.0f, 21.0f), + "rt" to Triple(337.0f, 55.0f, 12.0f), + "start" to Triple(295.0f, 98.0f, 8.0f), + "x" to Triple(334.0f, 142.0f, 12.0f), + "y" to Triple(358.0f, 118.0f, 12.0f), + ), + ), + PadArt.DUALSHOCK4 to PadArtRef( + drawableRes = R.drawable.pad_dualshock4, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(362.0f, 168.0f, 13.0f), + "b" to Triple(384.0f, 146.0f, 13.0f), + "dpad_down" to Triple(138.0f, 164.0f, 10.0f), + "dpad_left" to Triple(120.0f, 146.0f, 10.0f), + "dpad_right" to Triple(156.0f, 146.0f, 10.0f), + "dpad_up" to Triple(138.0f, 128.0f, 10.0f), + "guide" to Triple(250.0f, 206.0f, 9.0f), + "l3" to Triple(195.0f, 218.0f, 27.0f), + "lb" to Triple(158.0f, 82.0f, 15.0f), + "lstick" to Triple(195.0f, 218.0f, 27.0f), + "lt" to Triple(158.0f, 66.0f, 15.0f), + "options" to Triple(336.0f, 112.0f, 8.0f), + "r3" to Triple(305.0f, 218.0f, 27.0f), + "rb" to Triple(342.0f, 82.0f, 15.0f), + "rstick" to Triple(305.0f, 218.0f, 27.0f), + "rt" to Triple(342.0f, 66.0f, 15.0f), + "share" to Triple(164.0f, 112.0f, 8.0f), + "x" to Triple(340.0f, 146.0f, 13.0f), + "y" to Triple(362.0f, 124.0f, 13.0f), + ), + ), + PadArt.DUALSHOCK3 to PadArtRef( + drawableRes = R.drawable.pad_dualshock3, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(364.0f, 164.0f, 12.0f), + "b" to Triple(386.0f, 142.0f, 12.0f), + "dpad_down" to Triple(136.0f, 162.0f, 10.0f), + "dpad_left" to Triple(116.0f, 142.0f, 10.0f), + "dpad_right" to Triple(156.0f, 142.0f, 10.0f), + "dpad_up" to Triple(136.0f, 122.0f, 10.0f), + "guide" to Triple(250.0f, 148.0f, 10.0f), + "l3" to Triple(192.0f, 206.0f, 26.0f), + "lb" to Triple(137.0f, 76.0f, 14.0f), + "lstick" to Triple(192.0f, 206.0f, 26.0f), + "lt" to Triple(137.0f, 64.0f, 14.0f), + "r3" to Triple(308.0f, 206.0f, 26.0f), + "rb" to Triple(362.0f, 76.0f, 14.0f), + "rstick" to Triple(308.0f, 206.0f, 26.0f), + "rt" to Triple(362.0f, 64.0f, 14.0f), + "select" to Triple(205.0f, 129.0f, 8.0f), + "start" to Triple(296.0f, 129.0f, 8.0f), + "x" to Triple(342.0f, 142.0f, 12.0f), + "y" to Triple(364.0f, 120.0f, 12.0f), + ), + ), + PadArt.SWITCH_PRO to PadArtRef( + drawableRes = R.drawable.pad_switch_pro, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(388.0f, 148.0f, 12.0f), + "b" to Triple(362.0f, 174.0f, 12.0f), + "capture" to Triple(225.0f, 150.0f, 7.0f), + "dpad_down" to Triple(195.0f, 232.0f, 10.0f), + "dpad_left" to Triple(175.0f, 212.0f, 10.0f), + "dpad_right" to Triple(215.0f, 212.0f, 10.0f), + "dpad_up" to Triple(195.0f, 192.0f, 10.0f), + "guide" to Triple(280.0f, 150.0f, 8.0f), + "home" to Triple(280.0f, 150.0f, 8.0f), + "l3" to Triple(150.0f, 148.0f, 26.0f), + "lb" to Triple(155.0f, 80.0f, 15.0f), + "lstick" to Triple(150.0f, 148.0f, 26.0f), + "lt" to Triple(155.0f, 66.0f, 15.0f), + "minus" to Triple(205.0f, 118.0f, 8.0f), + "plus" to Triple(295.0f, 118.0f, 8.0f), + "r3" to Triple(305.0f, 212.0f, 26.0f), + "rb" to Triple(345.0f, 80.0f, 15.0f), + "rstick" to Triple(305.0f, 212.0f, 26.0f), + "rt" to Triple(345.0f, 66.0f, 15.0f), + "x" to Triple(362.0f, 122.0f, 12.0f), + "y" to Triple(336.0f, 148.0f, 12.0f), + ), + ), + PadArt.EIGHTBITDO to PadArtRef( + drawableRes = R.drawable.pad_eightbitdo, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(390.0f, 132.0f, 13.0f), + "b" to Triple(366.0f, 156.0f, 13.0f), + "dpad_down" to Triple(132.0f, 154.0f, 10.0f), + "dpad_left" to Triple(110.0f, 132.0f, 10.0f), + "dpad_right" to Triple(154.0f, 132.0f, 10.0f), + "dpad_up" to Triple(132.0f, 110.0f, 10.0f), + "l3" to Triple(195.0f, 202.0f, 22.0f), + "lb" to Triple(150.0f, 90.0f, 14.0f), + "lstick" to Triple(195.0f, 202.0f, 22.0f), + "lt" to Triple(150.0f, 74.0f, 14.0f), + "r3" to Triple(305.0f, 202.0f, 22.0f), + "rb" to Triple(350.0f, 90.0f, 14.0f), + "rstick" to Triple(305.0f, 202.0f, 22.0f), + "rt" to Triple(350.0f, 74.0f, 14.0f), + "select" to Triple(219.0f, 120.0f, 10.0f), + "start" to Triple(281.0f, 120.0f, 10.0f), + "x" to Triple(366.0f, 108.0f, 13.0f), + "y" to Triple(342.0f, 132.0f, 13.0f), + ), + ), + PadArt.GAMECUBE to PadArtRef( + drawableRes = R.drawable.pad_gamecube, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(365.0f, 142.0f, 18.0f), + "b" to Triple(340.0f, 154.0f, 10.0f), + "cstick" to Triple(325.0f, 212.0f, 19.0f), + "dpad_down" to Triple(195.0f, 237.0f, 9.0f), + "dpad_left" to Triple(180.0f, 222.0f, 9.0f), + "dpad_right" to Triple(210.0f, 222.0f, 9.0f), + "dpad_up" to Triple(195.0f, 207.0f, 9.0f), + "l3" to Triple(145.0f, 142.0f, 24.0f), + "lb" to Triple(150.0f, 92.0f, 14.0f), + "lstick" to Triple(145.0f, 142.0f, 24.0f), + "lt" to Triple(150.0f, 78.0f, 14.0f), + "rb" to Triple(350.0f, 92.0f, 14.0f), + "rt" to Triple(350.0f, 78.0f, 14.0f), + "start" to Triple(250.0f, 158.0f, 8.0f), + "x" to Triple(388.0f, 142.0f, 12.0f), + "y" to Triple(365.0f, 120.0f, 12.0f), + ), + ), + PadArt.SNES to PadArtRef( + drawableRes = R.drawable.pad_snes, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(385.0f, 160.0f, 13.0f), + "b" to Triple(372.0f, 197.0f, 13.0f), + "dpad_down" to Triple(142.0f, 194.0f, 11.0f), + "dpad_left" to Triple(120.0f, 172.0f, 11.0f), + "dpad_right" to Triple(164.0f, 172.0f, 11.0f), + "dpad_up" to Triple(142.0f, 150.0f, 11.0f), + "l" to Triple(130.0f, 90.0f, 16.0f), + "r" to Triple(370.0f, 90.0f, 16.0f), + "select" to Triple(215.0f, 190.0f, 11.0f), + "start" to Triple(250.0f, 180.0f, 11.0f), + "x" to Triple(348.0f, 147.0f, 13.0f), + "y" to Triple(335.0f, 184.0f, 13.0f), + ), + ), + PadArt.STEAM to PadArtRef( + drawableRes = R.drawable.pad_steam, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(348.0f, 148.0f, 11.0f), + "b" to Triple(366.0f, 130.0f, 11.0f), + "dpad_down" to Triple(150.0f, 148.0f, 10.0f), + "dpad_left" to Triple(132.0f, 132.0f, 10.0f), + "dpad_right" to Triple(168.0f, 132.0f, 10.0f), + "dpad_up" to Triple(150.0f, 116.0f, 10.0f), + "guide" to Triple(250.0f, 116.0f, 14.0f), + "l3" to Triple(200.0f, 148.0f, 11.0f), + "lb" to Triple(175.0f, 86.0f, 14.0f), + "lstick" to Triple(200.0f, 148.0f, 19.0f), + "lt" to Triple(175.0f, 72.0f, 12.0f), + "menu" to Triple(278.0f, 120.0f, 6.0f), + "qam" to Triple(250.0f, 193.0f, 9.0f), + "r3" to Triple(300.0f, 148.0f, 11.0f), + "rb" to Triple(325.0f, 86.0f, 14.0f), + "rstick" to Triple(300.0f, 148.0f, 19.0f), + "rt" to Triple(325.0f, 72.0f, 12.0f), + "view" to Triple(222.0f, 120.0f, 6.0f), + "x" to Triple(348.0f, 112.0f, 11.0f), + "y" to Triple(330.0f, 130.0f, 11.0f), + ), + ), + PadArt.GENERIC to PadArtRef( + drawableRes = R.drawable.pad_generic, + vbW = 500.0f, vbH = 350.0f, + el = mapOf( + "a" to Triple(372.0f, 170.0f, 13.0f), + "b" to Triple(400.0f, 142.0f, 13.0f), + "back" to Triple(224.0f, 140.0f, 8.0f), + "dpad_down" to Triple(197.0f, 228.0f, 11.0f), + "dpad_left" to Triple(179.0f, 210.0f, 11.0f), + "dpad_right" to Triple(215.0f, 210.0f, 11.0f), + "dpad_up" to Triple(197.0f, 192.0f, 11.0f), + "l3" to Triple(150.0f, 155.0f, 14.0f), + "lb" to Triple(160.0f, 82.0f, 14.0f), + "lstick" to Triple(150.0f, 155.0f, 24.0f), + "lt" to Triple(160.0f, 66.0f, 12.0f), + "r3" to Triple(318.0f, 215.0f, 14.0f), + "rb" to Triple(340.0f, 82.0f, 14.0f), + "rstick" to Triple(318.0f, 215.0f, 24.0f), + "rt" to Triple(340.0f, 66.0f, 12.0f), + "start" to Triple(276.0f, 140.0f, 8.0f), + "x" to Triple(344.0f, 142.0f, 13.0f), + "y" to Triple(372.0f, 114.0f, 13.0f), + ), + ), +) diff --git a/app/src/main/shared/ui/controllertest/VisualControllerBinder.kt b/app/src/main/shared/ui/controllertest/VisualControllerBinder.kt new file mode 100644 index 000000000..ee63022e6 --- /dev/null +++ b/app/src/main/shared/ui/controllertest/VisualControllerBinder.kt @@ -0,0 +1,506 @@ +package com.winlator.cmod.shared.ui.controllertest + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R +import com.winlator.cmod.runtime.input.controls.Binding +import com.winlator.cmod.runtime.input.controls.ControlsProfile +import com.winlator.cmod.runtime.input.controls.ExternalController +import com.winlator.cmod.shared.theme.WinNativeDanger +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary + +private val BindWarn = Color(0xFFE7B64C) +private val BindRowFill = Color(0xFF14141E) + +@Composable +fun VisualControllerBinder( + profile: ControlsProfile?, + art: PadArt, + snapshot: ControllerTestSnapshot?, + controllerId: String?, + controllerName: String?, + allProfiles: List, + onSelectProfile: (ControlsProfile) -> Unit, + onCreateProfile: (String) -> Unit, + onRenameProfile: (() -> Unit)?, + onSaved: () -> Unit, + onOpenAllBindings: (() -> Unit)?, + onSelectNone: (() -> Unit)? = null, + onDeleteProfile: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + var bindRev by remember { mutableIntStateOf(0) } + val controller: ExternalController? = + remember(profile, controllerId, bindRev) { + profile?.let { resolveBindController(it, controllerId, controllerName) } + } + + var selectedId by remember(profile) { mutableStateOf(null) } + var category by remember { mutableStateOf(BindCategory.KEYBOARD) } + var showNew by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + var profMenu by remember { mutableStateOf(false) } + + fun bumpSaved() { + bindRev++ + onSaved() + } + + val boundN = remember(bindRev, controller) { boundCount(controller) } + val labels = + remember(bindRev, controller) { + BIND_TARGETS.associate { it.id to bindingLabelOrNull(controller, it.id) } + } + val boundBindIds = remember(bindRev, controller) { labels.filterValues { it != null }.keys.toSet() } + + Column(modifier.verticalScroll(rememberScrollState())) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.controller_bind_profile), + fontSize = 11.sp, + color = WinNativeTextSecondary, + ) + Spacer(Modifier.width(8.dp)) + Box { + OutlinedButton( + onClick = { profMenu = true }, + shape = RoundedCornerShape(9.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + modifier = Modifier.widthIn(max = 180.dp), + ) { + Text( + profile?.name ?: stringResource(R.string.controller_bind_profile_none), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + DropdownMenu( + expanded = profMenu, + onDismissRequest = { profMenu = false }, + modifier = Modifier.testMenuCard(), + ) { + if (onSelectNone != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.controller_bind_profile_native)) }, + onClick = { + profMenu = false + onSelectNone() + }, + ) + TestMenuDivider() + } + allProfiles.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { + profMenu = false + onSelectProfile(p) + }, + ) + TestMenuDivider() + } + if (profile != null) { + if (onRenameProfile != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.controller_bind_rename)) }, + onClick = { + profMenu = false + onRenameProfile() + }, + ) + } + if (onDeleteProfile != null) { + TestMenuDivider() + DropdownMenuItem( + text = { + Text( + stringResource(R.string.controller_bind_delete), + color = WinNativeDanger, + ) + }, + onClick = { + profMenu = false + onDeleteProfile() + }, + ) + } + } + } + } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { showNew = !showNew }) { + Text(stringResource(R.string.controller_bind_new), fontSize = 12.sp) + } + Spacer(Modifier.weight(1f)) + if (onOpenAllBindings != null) { + TextButton(onClick = onOpenAllBindings) { + Text(stringResource(R.string.controller_bind_all_bindings), fontSize = 12.sp) + } + } + } + if (showNew) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + singleLine = true, + placeholder = { + Text(stringResource(R.string.controller_bind_new_name), fontSize = 12.sp) + }, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + if (newName.isNotBlank()) { + onCreateProfile(newName.trim()) + newName = "" + showNew = false + } + }, + shape = RoundedCornerShape(9.dp), + ) { Text(stringResource(R.string.controller_bind_create), fontSize = 12.sp) } + } + } + Spacer(Modifier.height(8.dp)) + + if (profile == null || controller == null) { + Text( + if (profile == null) { + stringResource(R.string.controller_bind_select_profile) + } else { + stringResource(R.string.controller_bind_no_controller) + }, + style = MaterialTheme.typography.bodySmall, + color = WinNativeTextSecondary, + ) + return@Column + } + + if (boundN > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .background(BindWarn.copy(alpha = 0.12f), RoundedCornerShape(10.dp)) + .border(1.dp, BindWarn.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.controller_bind_remap_on, boundN, BIND_TARGETS.size), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = BindWarn, + ) + Text( + stringResource(R.string.controller_bind_remap_warning), + fontSize = 11.sp, + color = WinNativeTextSecondary, + ) + } + Spacer(Modifier.width(8.dp)) + OutlinedButton( + onClick = { + fillNative(controller, profile) + bumpSaved() + }, + shape = RoundedCornerShape(9.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), + ) { Text(stringResource(R.string.controller_bind_fill_native), fontSize = 11.sp) } + } + Spacer(Modifier.height(8.dp)) + } + + val padBox: @Composable () -> Unit = { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + PadArtView( + art = art, + snapshot = snapshot, + boundBindIds = boundBindIds, + selectedBindId = selectedId, + onTapBind = { id -> selectedId = id }, + ) + } + } + val panel: @Composable () -> Unit = { + val current = selectedId + if (current == null) { + BindSummary(labels, boundN) { id -> selectedId = id } + } else { + BindEditor( + id = current, + currentLabel = labels[current], + category = category, + onCategory = { category = it }, + onPick = { bnd -> + setTarget(controller, profile, current, bnd) + bumpSaved() + }, + onNative = { + val nat = BIND_BY_ID[current]?.native + if (nat != null) { + setTarget(controller, profile, current, nat) + bumpSaved() + } + }, + onClear = { + clearTarget(controller, profile, current) + bumpSaved() + }, + onDone = { selectedId = null }, + ) + } + } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth >= 520.dp) { + Row(Modifier.fillMaxWidth()) { + Box(Modifier.weight(1f), contentAlignment = Alignment.TopCenter) { padBox() } + Spacer(Modifier.width(12.dp)) + Box(Modifier.weight(1f)) { panel() } + } + } else { + Column(Modifier.fillMaxWidth()) { + padBox() + Spacer(Modifier.height(12.dp)) + panel() + } + } + } + } +} + +@Composable +private fun BindSummary( + labels: Map, + boundN: Int, + onSelect: (String) -> Unit, +) { + val context = LocalContext.current + Column(Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.controller_bind_summary, boundN, BIND_TARGETS.size), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = WinNativeTextPrimary, + ) + Spacer(Modifier.height(6.dp)) + BIND_TARGETS.forEach { t -> + val label = labels[t.id] + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .background(BindRowFill, RoundedCornerShape(9.dp)) + .clickable { onSelect(t.id) } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text( + bindTargetName(context, t), + fontSize = 12.sp, + color = WinNativeTextPrimary, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text("→", fontSize = 12.sp, color = WinNativeTextSecondary) + Spacer(Modifier.width(8.dp)) + Text( + label ?: stringResource(R.string.controller_bind_native), + fontSize = 11.sp, + color = if (label != null) BIND_BLUE else WinNativeTextSecondary, + fontWeight = if (label != null) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } +} + +@Composable +private fun BindEditor( + id: String, + currentLabel: String?, + category: BindCategory, + onCategory: (BindCategory) -> Unit, + onPick: (Binding) -> Unit, + onNative: () -> Unit, + onClear: () -> Unit, + onDone: () -> Unit, +) { + val context = LocalContext.current + val t = BIND_BY_ID[id] + val targetName = t?.let { bindTargetName(context, it) } ?: id + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.controller_bind_label), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = WinNativeTextPrimary, + ) + Spacer(Modifier.width(4.dp)) + Text( + targetName, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = BIND_BLUE, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDone) { + Text(stringResource(R.string.common_ui_done), fontSize = 12.sp) + } + } + Text( + stringResource( + R.string.controller_bind_current, + currentLabel ?: stringResource(R.string.controller_bind_current_native), + ), + fontSize = 12.sp, + color = if (currentLabel != null) BIND_BLUE else WinNativeTextSecondary, + ) + Spacer(Modifier.height(6.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + BindCategory.entries.forEach { cat -> + val on = cat == category + Box( + Modifier + .background( + if (on) BIND_BLUE.copy(alpha = 0.2f) else BindRowFill, + RoundedCornerShape(8.dp), + ) + .border(1.dp, if (on) BIND_BLUE else WinNativeOutline, RoundedCornerShape(8.dp)) + .clickable { onCategory(cat) } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + when (cat) { + BindCategory.KEYBOARD -> stringResource(R.string.controller_bind_category_keyboard) + BindCategory.MOUSE -> stringResource(R.string.controller_bind_category_mouse) + BindCategory.XBOX -> stringResource(R.string.controller_bind_category_xbox) + BindCategory.NONE -> stringResource(R.string.controller_bind_category_none) + }, + fontSize = 11.sp, + color = if (on) WinNativeTextPrimary else WinNativeTextSecondary, + fontWeight = FontWeight.Medium, + ) + } + } + } + Spacer(Modifier.height(8.dp)) + + val optionLabels = categoryLabels(category) + val optionValues = categoryValues(category) + val n = minOf(optionLabels.size, optionValues.size) + val perRow = 4 + var i = 0 + while (i < n) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + ) { + var col = 0 + while (col < perRow && i < n) { + val idx = i + OutlinedButton( + onClick = { onPick(optionValues[idx]) }, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp), + modifier = Modifier.weight(1f), + ) { + Text( + optionLabels[idx], + fontSize = 10.5.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + i++ + col++ + } + while (col < perRow) { + Spacer(Modifier.weight(1f)) + col++ + } + } + } + + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (t?.native != null) { + OutlinedButton( + onClick = onNative, + shape = RoundedCornerShape(9.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + stringResource(R.string.controller_bind_native_button, targetName), + fontSize = 11.sp, + ) + } + } + Button( + onClick = onClear, + shape = RoundedCornerShape(9.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color(0xFFE2564E).copy(alpha = 0.85f), + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { Text(stringResource(R.string.controller_bind_clear), fontSize = 11.sp) } + } + } +} diff --git a/settings.gradle b/settings.gradle index 1321ac474..00233dc0c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,6 +13,12 @@ dependencyResolutionManagement { maven { url 'https://central.sonatype.com/repository/maven-snapshots/' } maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } maven { url 'https://jitpack.io' } + exclusiveContent { + forRepository { + maven { url = uri("${rootDir}/vendor/maven") } + } + filter { includeGroup 'org.libsdl.android' } + } } } diff --git a/vendor/maven/README.md b/vendor/maven/README.md new file mode 100644 index 000000000..24f647176 --- /dev/null +++ b/vendor/maven/README.md @@ -0,0 +1,51 @@ +# Vendored Maven artifacts + +A small in-repo **Maven repository** for third-party artifacts we do not want to depend on an +external server for. It is wired up in `settings.gradle` with an `exclusiveContent` block, so the +groups below are *only* ever resolved from here and never looked up remotely: + +```groovy +exclusiveContent { + forRepository { + maven { url = uri("${rootDir}/vendor/maven") } + } + filter { includeGroup 'org.libsdl.android' } +} +``` + +Do **not** edit any file in this tree by hand — the checksums must match the bytes. + +## `org.libsdl.android:SDL3` — `3.4.16` + +The official SDL 3.4.16 Android release AAR, used for the optional **Steam Controller** support. +SDL's HIDAPI Steam drivers are what read the 2015 and 2026 Steam Controllers over Bluetooth LE and +USB; see `SteamControllerBackend` and `app/src/main/cpp/steamctrl/steam_controller_bridge.cpp`. +SDL publishes this AAR only as a GitHub release asset, not to a Maven repository, so it is +vendored here. + +- `SDL3-3.4.16.aar` is byte-identical to the AAR inside the upstream release asset + `SDL3-devel-3.4.16-android.zip` from + . + AAR sha256 `03710fc7b49cc070551446841a843840fabf2aaaaa3043cd5739292a55e4e61c` + (recorded in `SDL3-3.4.16.aar.sha256`). +- `SDL3-3.4.16.pom` is hand-written (packaging `aar`, no dependencies) — upstream ships no POM. +- The AAR carries SDL's Java classes (`org.libsdl.app.*`, including `HIDDeviceManager`, which is + how SDL reaches Bluetooth/USB HID on Android) plus a prefab package. `libsteamctrl.so` links + `SDL3::SDL3` through prefab (`buildFeatures.prefab` in `app/build.gradle`), and that link is also + what packages `libSDL3.so` into the APK — the AAR has no `jni/` folder. +- Consumed as `implementation 'org.libsdl.android:SDL3:3.4.16@aar'` (`@aar` because there is no + Gradle module metadata upstream). +- License: **zlib** (`META-INF/LICENSE.txt` inside the AAR). See `EMULATOR_CREDITS.md`. + +The AAR's prefab metadata records NDK r28; WinNative builds with r27 and prefab accepts it. Only +`arm64-v8a` is extracted, since that is the single ABI in `abiFilters`. + +### How to update + +1. Download the new `SDL3-devel--android.zip` from the SDL releases page and verify it + against the release notes. +2. Copy the AAR out unmodified into `org/libsdl/android/SDL3//`, alongside a matching + `.pom` and an `.aar.sha256`. +3. Bump the version in `app/build.gradle`. +4. Delete the old version directory. +5. Build and run before merging — `libsteamctrl.so` links against it directly. diff --git a/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.aar b/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.aar new file mode 100644 index 000000000..a796870c6 Binary files /dev/null and b/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.aar differ diff --git a/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.aar.sha256 b/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.aar.sha256 new file mode 100644 index 000000000..c941ca4de --- /dev/null +++ b/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.aar.sha256 @@ -0,0 +1 @@ +03710fc7b49cc070551446841a843840fabf2aaaaa3043cd5739292a55e4e61c diff --git a/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.pom b/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.pom new file mode 100644 index 000000000..e06b859f8 --- /dev/null +++ b/vendor/maven/org/libsdl/android/SDL3/3.4.16/SDL3-3.4.16.pom @@ -0,0 +1,21 @@ + + + + 4.0.0 + org.libsdl.android + SDL3 + 3.4.16 + aar + SDL3 + Simple DirectMedia Layer 3.4.16 for Android (official release AAR, unmodified) + https://github.com/libsdl-org/SDL + + + zlib License + https://github.com/libsdl-org/SDL/blob/release-3.4.16/LICENSE.txt + + +