From 50ecad547ea7e88301583f17c9f1eea2cc75b0af Mon Sep 17 00:00:00 2001 From: t895 Date: Fri, 16 Feb 2024 21:19:17 -0500 Subject: android: Input mapping --- src/android/app/src/main/AndroidManifest.xml | 1 + .../main/java/org/yuzu/yuzu_emu/NativeLibrary.kt | 176 ----- .../main/java/org/yuzu/yuzu_emu/YuzuApplication.kt | 2 + .../yuzu/yuzu_emu/activities/EmulationActivity.kt | 39 +- .../yuzu/yuzu_emu/features/input/NativeInput.kt | 416 ++++++++++++ .../yuzu_emu/features/input/YuzuInputDevice.kt | 93 +++ .../yuzu/yuzu_emu/features/input/YuzuVibrator.kt | 76 +++ .../features/input/model/AnalogDirection.kt | 11 + .../yuzu_emu/features/input/model/ButtonName.kt | 19 + .../yuzu_emu/features/input/model/InputType.kt | 13 + .../yuzu_emu/features/input/model/NativeAnalog.kt | 14 + .../yuzu_emu/features/input/model/NativeButton.kt | 38 ++ .../yuzu_emu/features/input/model/NativeTrigger.kt | 10 + .../features/input/model/NpadStyleIndex.kt | 30 + .../yuzu_emu/features/input/model/PlayerInput.kt | 83 +++ .../yuzu_emu/features/settings/model/Settings.kt | 15 +- .../settings/model/view/AnalogInputSetting.kt | 31 + .../settings/model/view/ButtonInputSetting.kt | 29 + .../settings/model/view/InputProfileSetting.kt | 32 + .../features/settings/model/view/InputSetting.kt | 134 ++++ .../settings/model/view/IntSingleChoiceSetting.kt | 38 ++ .../settings/model/view/ModifierInputSetting.kt | 31 + .../settings/model/view/RunnableSetting.kt | 2 +- .../features/settings/ui/InputDialogFragment.kt | 300 +++++++++ .../features/settings/ui/InputProfileAdapter.kt | 68 ++ .../settings/ui/InputProfileDialogFragment.kt | 155 +++++ .../settings/ui/NewInputProfileDialogFragment.kt | 79 +++ .../features/settings/ui/SettingsActivity.kt | 3 +- .../features/settings/ui/SettingsAdapter.kt | 247 ++++++- .../features/settings/ui/SettingsDialogFragment.kt | 287 ++++++++ .../features/settings/ui/SettingsFragment.kt | 86 ++- .../settings/ui/SettingsFragmentPresenter.kt | 730 ++++++++++++++++++++- .../features/settings/ui/SettingsSearchFragment.kt | 191 ++++++ .../features/settings/ui/SettingsViewModel.kt | 112 ++++ .../ui/viewholder/InputProfileViewHolder.kt | 33 + .../settings/ui/viewholder/InputViewHolder.kt | 71 ++ .../ui/viewholder/SingleChoiceViewHolder.kt | 3 + .../yuzu/yuzu_emu/fragments/EmulationFragment.kt | 9 + .../yuzu_emu/fragments/HomeSettingsFragment.kt | 14 + .../yuzu_emu/fragments/SettingsDialogFragment.kt | 227 ------- .../yuzu_emu/fragments/SettingsSearchFragment.kt | 193 ------ .../org/yuzu/yuzu_emu/model/SettingsViewModel.kt | 71 -- .../java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt | 99 ++- .../yuzu_emu/overlay/InputOverlayDrawableButton.kt | 7 +- .../yuzu_emu/overlay/InputOverlayDrawableDpad.kt | 25 +- .../overlay/InputOverlayDrawableJoystick.kt | 15 +- .../java/org/yuzu/yuzu_emu/utils/InputHandler.kt | 456 ++----------- .../java/org/yuzu/yuzu_emu/utils/NativeConfig.kt | 15 + .../main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt | 6 +- .../java/org/yuzu/yuzu_emu/utils/ParamPackage.kt | 141 ++++ src/android/app/src/main/jni/CMakeLists.txt | 1 + src/android/app/src/main/jni/android_config.cpp | 141 ++++ src/android/app/src/main/jni/android_config.h | 7 + .../app/src/main/jni/emu_window/emu_window.cpp | 49 +- .../app/src/main/jni/emu_window/emu_window.h | 15 +- src/android/app/src/main/jni/native.cpp | 167 +---- src/android/app/src/main/jni/native.h | 5 +- src/android/app/src/main/jni/native_config.cpp | 117 +++- src/android/app/src/main/jni/native_input.cpp | 631 ++++++++++++++++++ .../app/src/main/res/drawable/button_anim.xml | 142 ++++ .../res/drawable/ic_controller_disconnected.xml | 9 + .../app/src/main/res/drawable/ic_more_vert.xml | 9 + .../app/src/main/res/drawable/ic_new_label.xml | 9 + .../app/src/main/res/drawable/ic_overlay.xml | 21 + src/android/app/src/main/res/drawable/ic_share.xml | 9 + .../main/res/drawable/stick_one_direction_anim.xml | 118 ++++ .../main/res/drawable/stick_two_direction_anim.xml | 173 +++++ .../res/layout-ldrtl/list_item_setting_input.xml | 63 ++ .../src/main/res/layout/dialog_input_profiles.xml | 6 + .../app/src/main/res/layout/dialog_mapping.xml | 26 + .../main/res/layout/list_item_input_profile.xml | 74 +++ .../main/res/layout/list_item_setting_input.xml | 63 ++ src/android/app/src/main/res/menu/menu_in_game.xml | 7 +- .../app/src/main/res/menu/menu_input_options.xml | 34 + .../main/res/navigation/settings_navigation.xml | 2 +- .../app/src/main/res/values-w600dp/dimens.xml | 2 + src/android/app/src/main/res/values/dimens.xml | 2 + src/android/app/src/main/res/values/strings.xml | 93 +++ src/common/android/id_cache.cpp | 163 +++++ src/common/android/id_cache.h | 24 + src/common/settings_input.h | 4 + src/hid_core/frontend/emulated_controller.cpp | 4 +- src/input_common/CMakeLists.txt | 10 +- src/input_common/drivers/android.cpp | 324 ++++++++- src/input_common/drivers/android.h | 123 +++- src/input_common/main.cpp | 28 +- 86 files changed, 6184 insertions(+), 1467 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsSearchFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt create mode 100644 src/android/app/src/main/jni/native_input.cpp create mode 100644 src/android/app/src/main/res/drawable/button_anim.xml create mode 100644 src/android/app/src/main/res/drawable/ic_controller_disconnected.xml create mode 100644 src/android/app/src/main/res/drawable/ic_more_vert.xml create mode 100644 src/android/app/src/main/res/drawable/ic_new_label.xml create mode 100644 src/android/app/src/main/res/drawable/ic_overlay.xml create mode 100644 src/android/app/src/main/res/drawable/ic_share.xml create mode 100644 src/android/app/src/main/res/drawable/stick_one_direction_anim.xml create mode 100644 src/android/app/src/main/res/drawable/stick_two_direction_anim.xml create mode 100644 src/android/app/src/main/res/layout-ldrtl/list_item_setting_input.xml create mode 100644 src/android/app/src/main/res/layout/dialog_input_profiles.xml create mode 100644 src/android/app/src/main/res/layout/dialog_mapping.xml create mode 100644 src/android/app/src/main/res/layout/list_item_input_profile.xml create mode 100644 src/android/app/src/main/res/layout/list_item_setting_input.xml create mode 100644 src/android/app/src/main/res/menu/menu_input_options.xml diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 7890b30ca..b037fc055 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ SPDX-License-Identifier: GPL-3.0-or-later + (null) @@ -127,112 +99,6 @@ object NativeLibrary { FileUtil.getFilename(Uri.parse(path)) } - /** - * Returns true if pro controller isn't available and handheld is - */ - external fun isHandheldOnly(): Boolean - - /** - * Changes controller type for a specific device. - * - * @param Device The input descriptor of the gamepad. - * @param Type The NpadStyleIndex of the gamepad. - */ - external fun setDeviceType(Device: Int, Type: Int): Boolean - - /** - * Handles event when a gamepad is connected. - * - * @param Device The input descriptor of the gamepad. - */ - external fun onGamePadConnectEvent(Device: Int): Boolean - - /** - * Handles event when a gamepad is disconnected. - * - * @param Device The input descriptor of the gamepad. - */ - external fun onGamePadDisconnectEvent(Device: Int): Boolean - - /** - * Handles button press events for a gamepad. - * - * @param Device The input descriptor of the gamepad. - * @param Button Key code identifying which button was pressed. - * @param Action Mask identifying which action is happening (button pressed down, or button released). - * @return If we handled the button press. - */ - external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean - - /** - * Handles joystick movement events. - * - * @param Device The device ID of the gamepad. - * @param Axis The axis ID - * @param x_axis The value of the x-axis represented by the given ID. - * @param y_axis The value of the y-axis represented by the given ID. - */ - external fun onGamePadJoystickEvent( - Device: Int, - Axis: Int, - x_axis: Float, - y_axis: Float - ): Boolean - - /** - * Handles motion events. - * - * @param delta_timestamp The finger id corresponding to this event - * @param gyro_x,gyro_y,gyro_z The value of the accelerometer sensor. - * @param accel_x,accel_y,accel_z The value of the y-axis - */ - external fun onGamePadMotionEvent( - Device: Int, - delta_timestamp: Long, - gyro_x: Float, - gyro_y: Float, - gyro_z: Float, - accel_x: Float, - accel_y: Float, - accel_z: Float - ): Boolean - - /** - * Signals and load a nfc tag - * - * @param data Byte array containing all the data from a nfc tag - */ - external fun onReadNfcTag(data: ByteArray?): Boolean - - /** - * Removes current loaded nfc tag - */ - external fun onRemoveNfcTag(): Boolean - - /** - * Handles touch press events. - * - * @param finger_id The finger id corresponding to this event - * @param x_axis The value of the x-axis. - * @param y_axis The value of the y-axis. - */ - external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float) - - /** - * Handles touch movement. - * - * @param x_axis The value of the instantaneous x-axis. - * @param y_axis The value of the instantaneous y-axis. - */ - external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float) - - /** - * Handles touch release events. - * - * @param finger_id The finger id corresponding to this event - */ - external fun onTouchReleased(finger_id: Int) - external fun setAppDirectory(directory: String) /** @@ -629,46 +495,4 @@ object NativeLibrary { * Checks if all necessary keys are present for decryption */ external fun areKeysPresent(): Boolean - - /** - * Button type for use in onTouchEvent - */ - object ButtonType { - const val BUTTON_A = 0 - const val BUTTON_B = 1 - const val BUTTON_X = 2 - const val BUTTON_Y = 3 - const val STICK_L = 4 - const val STICK_R = 5 - const val TRIGGER_L = 6 - const val TRIGGER_R = 7 - const val TRIGGER_ZL = 8 - const val TRIGGER_ZR = 9 - const val BUTTON_PLUS = 10 - const val BUTTON_MINUS = 11 - const val DPAD_LEFT = 12 - const val DPAD_UP = 13 - const val DPAD_RIGHT = 14 - const val DPAD_DOWN = 15 - const val BUTTON_SL = 16 - const val BUTTON_SR = 17 - const val BUTTON_HOME = 18 - const val BUTTON_CAPTURE = 19 - } - - /** - * Stick type for use in onTouchEvent - */ - object StickType { - const val STICK_L = 0 - const val STICK_R = 1 - } - - /** - * Button states - */ - object ButtonState { - const val RELEASED = 0 - const val PRESSED = 1 - } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt index 76778c10a..72943f33e 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -7,6 +7,7 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import org.yuzu.yuzu_emu.features.input.NativeInput import java.io.File import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.DocumentsTree @@ -37,6 +38,7 @@ class YuzuApplication : Application() { documentsTree = DocumentsTree() DirectoryInitialization.start() GpuDriverHelper.initializeDriverParameters() + NativeInput.reloadInputDevices() NativeLibrary.logDeviceInfo() Log.logDeviceInfo() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt index 7a8d03610..0b70fccec 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -39,6 +39,7 @@ import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.databinding.ActivityEmulationBinding +import org.yuzu.yuzu_emu.features.input.NativeInput import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.features.settings.model.Settings @@ -47,7 +48,9 @@ import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.utils.InputHandler import org.yuzu.yuzu_emu.utils.Log import org.yuzu.yuzu_emu.utils.MemoryUtil +import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.NfcReader +import org.yuzu.yuzu_emu.utils.ParamPackage import org.yuzu.yuzu_emu.utils.ThemeHelper import java.text.NumberFormat import kotlin.math.roundToInt @@ -63,8 +66,6 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { private var motionTimestamp: Long = 0 private var flipMotionOrientation: Boolean = false - private var controllerIds = InputHandler.getGameControllerIds() - private val actionPause = "ACTION_EMULATOR_PAUSE" private val actionPlay = "ACTION_EMULATOR_PLAY" private val actionMute = "ACTION_EMULATOR_MUTE" @@ -78,6 +79,27 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { super.onCreate(savedInstanceState) + InputHandler.updateControllerData() + val playerOne = NativeConfig.getInputSettings(true)[0] + if (!playerOne.hasMapping() && InputHandler.androidControllers.isNotEmpty()) { + var params: ParamPackage? = null + for (controller in InputHandler.registeredControllers) { + if (controller.get("port", -1) == 0) { + params = controller + break + } + } + + if (params != null) { + NativeInput.updateMappingsWithDefault( + 0, + params, + params.get("display", getString(R.string.unknown)) + ) + NativeConfig.saveGlobalConfig() + } + } + binding = ActivityEmulationBinding.inflate(layoutInflater) setContentView(binding.root) @@ -95,8 +117,6 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { nfcReader = NfcReader(this) nfcReader.initialize() - InputHandler.initialize() - val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) { if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) { @@ -147,7 +167,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { super.onResume() nfcReader.startScanning() startMotionSensorListener() - InputHandler.updateControllerIds() + InputHandler.updateControllerData() buildPictureInPictureParams() } @@ -172,6 +192,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { super.onNewIntent(intent) setIntent(intent) nfcReader.onNewIntent(intent) + InputHandler.updateControllerData() } override fun dispatchKeyEvent(event: KeyEvent): Boolean { @@ -244,8 +265,8 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { } val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000 motionTimestamp = event.timestamp - NativeLibrary.onGamePadMotionEvent( - NativeLibrary.Player1Device, + NativeInput.onDeviceMotionEvent( + NativeInput.Player1Device, deltaTimestamp, gyro[0], gyro[1], @@ -254,8 +275,8 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { accel[1], accel[2] ) - NativeLibrary.onGamePadMotionEvent( - NativeLibrary.ConsoleDevice, + NativeInput.onDeviceMotionEvent( + NativeInput.ConsoleDevice, deltaTimestamp, gyro[0], gyro[1], diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt new file mode 100644 index 000000000..15d776311 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input + +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.ButtonName +import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.ParamPackage +import android.view.InputDevice + +object NativeInput { + /** + * Default controller id for each device + */ + const val Player1Device = 0 + const val Player2Device = 1 + const val Player3Device = 2 + const val Player4Device = 3 + const val Player5Device = 4 + const val Player6Device = 5 + const val Player7Device = 6 + const val Player8Device = 7 + const val ConsoleDevice = 8 + + /** + * Button states + */ + object ButtonState { + const val RELEASED = 0 + const val PRESSED = 1 + } + + /** + * Returns true if pro controller isn't available and handheld is. + * Intended to check where the input overlay should direct its inputs. + */ + external fun isHandheldOnly(): Boolean + + /** + * Handles button press events for a gamepad. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param buttonId The Android Keycode corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + external fun onGamePadButtonEvent( + guid: String, + port: Int, + buttonId: Int, + action: Int + ) + + /** + * Handles axis movement events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param axis The axis ID. + * @param value Value along the given axis. + */ + external fun onGamePadAxisEvent(guid: String, port: Int, axis: Int, value: Float) + + /** + * Handles motion events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onGamePadMotionEvent( + guid: String, + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Signals and load a nfc tag + * @param data Byte array containing all the data from a nfc tag. + */ + external fun onReadNfcTag(data: ByteArray?) + + /** + * Removes current loaded nfc tag. + */ + external fun onRemoveNfcTag() + + /** + * Handles touch press events. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchPressed(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch movement. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchMoved(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch release events. + * @param fingerId The finger id corresponding to this event + */ + external fun onTouchReleased(fingerId: Int) + + /** + * Sends a button input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param button The [NativeButton] corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + fun onOverlayButtonEvent(port: Int, button: NativeButton, action: Int) = + onOverlayButtonEventImpl(port, button.int, action) + + private external fun onOverlayButtonEventImpl(port: Int, buttonId: Int, action: Int) + + /** + * Sends a joystick input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param stick The [NativeAnalog] corresponding to this event. + * @param xAxis Value along the X axis. + * @param yAxis Value along the Y axis. + */ + fun onOverlayJoystickEvent(port: Int, stick: NativeAnalog, xAxis: Float, yAxis: Float) = + onOverlayJoystickEventImpl(port, stick.int, xAxis, yAxis) + + private external fun onOverlayJoystickEventImpl( + port: Int, + stickId: Int, + xAxis: Float, + yAxis: Float + ) + + /** + * Handles motion events for the global virtual controllers. + * @param port Port determined by controller connection order + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onDeviceMotionEvent( + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Reloads all input devices from the currently loaded Settings::values.players into HID Core + */ + external fun reloadInputDevices() + + /** + * Registers a controller to be used with mapping + * @param device An [InputDevice] or the input overlay wrapped with [YuzuInputDevice] + */ + external fun registerController(device: YuzuInputDevice) + + /** + * Gets the names of input devices that have been registered with the input subsystem via [registerController] + */ + external fun getInputDevices(): Array + + /** + * Reads all input profiles from disk. Must be called before creating a profile picker. + */ + external fun loadInputProfiles() + + /** + * Gets the names of each available input profile. + */ + external fun getInputProfileNames(): Array + + /** + * Checks if the user-provided name for an input profile is valid. + * @param name User-provided name for an input profile. + * @return Whether [name] is valid or not. + */ + external fun isProfileNameValid(name: String): Boolean + + /** + * Creates a new input profile. + * @param name The new profile's name. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether creating the profile was successful or not. + */ + external fun createProfile(name: String, playerIndex: Int): Boolean + + /** + * Deletes an input profile. + * @param name Name of the profile to delete. + * @param playerIndex Index of the player that's currently being edited. Used to remove the profile + * name from this player's config if they have it loaded. + * @return Whether deleting this profile was successful or not. + */ + external fun deleteProfile(name: String, playerIndex: Int): Boolean + + /** + * Loads an input profile. + * @param name Name of the input profile to load. + * @param playerIndex Index of the player that will have this profile loaded. + * @return Whether loading this profile was successful or not. + */ + external fun loadProfile(name: String, playerIndex: Int): Boolean + + /** + * Saves an input profile. + * @param name Name of the profile to save. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether saving the profile was successful or not. + */ + external fun saveProfile(name: String, playerIndex: Int): Boolean + + /** + * Intended to be used immediately before a call to [NativeConfig.saveControlPlayerValues] + * Must be used while per-game config is loaded. + */ + external fun loadPerGameConfiguration( + playerIndex: Int, + selectedIndex: Int, + selectedProfileName: String + ) + + /** + * Tells the input subsystem to start listening for inputs to map. + * @param type Type of input to map as shown by the int property in each [InputType]. + */ + external fun beginMapping(type: Int) + + /** + * Gets an input's [ParamPackage] as a serialized string. Used for input verification before mapping. + * Must be run after [beginMapping] and before [stopMapping]. + */ + external fun getNextInput(): String + + /** + * Tells the input subsystem to stop listening for inputs to map. + */ + external fun stopMapping() + + /** + * Updates a controller's mappings with auto-mapping params. + * @param playerIndex Index of the player to auto-map. + * @param deviceParams [ParamPackage] representing the device to auto-map as received + * from [getInputDevices]. + * @param displayName Name of the device to auto-map as received from the "display" param in [deviceParams]. + * Intended to be a way to provide a default name for a controller if the "display" param is empty. + */ + fun updateMappingsWithDefault( + playerIndex: Int, + deviceParams: ParamPackage, + displayName: String + ) = updateMappingsWithDefaultImpl(playerIndex, deviceParams.serialize(), displayName) + + private external fun updateMappingsWithDefaultImpl( + playerIndex: Int, + deviceParams: String, + displayName: String + ) + + /** + * Gets the params for a specific button. + * @param playerIndex Index of the player to get params from. + * @param button The [NativeButton] to get params for. + * @return A [ParamPackage] representing a player's specific button. + */ + fun getButtonParam(playerIndex: Int, button: NativeButton): ParamPackage = + ParamPackage(getButtonParamImpl(playerIndex, button.int)) + + private external fun getButtonParamImpl(playerIndex: Int, buttonId: Int): String + + /** + * Sets the params for a specific button. + * @param playerIndex Index of the player to set params for. + * @param button The [NativeButton] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setButtonParam(playerIndex: Int, button: NativeButton, param: ParamPackage) = + setButtonParamImpl(playerIndex, button.int, param.serialize()) + + private external fun setButtonParamImpl(playerIndex: Int, buttonId: Int, param: String) + + /** + * Gets the params for a specific stick. + * @param playerIndex Index of the player to get params from. + * @param stick The [NativeAnalog] to get params for. + * @return A [ParamPackage] representing a player's specific stick. + */ + fun getStickParam(playerIndex: Int, stick: NativeAnalog): ParamPackage = + ParamPackage(getStickParamImpl(playerIndex, stick.int)) + + private external fun getStickParamImpl(playerIndex: Int, stickId: Int): String + + /** + * Sets the params for a specific stick. + * @param playerIndex Index of the player to set params for. + * @param stick The [NativeAnalog] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setStickParam(playerIndex: Int, stick: NativeAnalog, param: ParamPackage) = + setStickParamImpl(playerIndex, stick.int, param.serialize()) + + private external fun setStickParamImpl(playerIndex: Int, stickId: Int, param: String) + + /** + * Gets the int representation of a [ButtonName]. Tells you what to show as the mapped input for + * a button/analog/other. + * @param param A [ParamPackage] that represents a specific button's params. + * @return The [ButtonName] for [param]. + */ + fun getButtonName(param: ParamPackage): ButtonName = + ButtonName.from(getButtonNameImpl(param.serialize())) + + private external fun getButtonNameImpl(param: String): Int + + /** + * Gets each supported [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get supported indexes for. + * @return List of each supported [NpadStyleIndex]. + */ + fun getSupportedStyleTags(playerIndex: Int): List = + getSupportedStyleTagsImpl(playerIndex).map { NpadStyleIndex.from(it) } + + private external fun getSupportedStyleTagsImpl(playerIndex: Int): IntArray + + /** + * Gets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get an [NpadStyleIndex] from. + * @return The [NpadStyleIndex] for a given player. + */ + fun getStyleIndex(playerIndex: Int): NpadStyleIndex = + NpadStyleIndex.from(getStyleIndexImpl(playerIndex)) + + private external fun getStyleIndexImpl(playerIndex: Int): Int + + /** + * Sets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to change. + * @param style The new style to set. + */ + fun setStyleIndex(playerIndex: Int, style: NpadStyleIndex) = + setStyleIndexImpl(playerIndex, style.int) + + private external fun setStyleIndexImpl(playerIndex: Int, styleIndex: Int) + + /** + * Checks if a device is a controller. + * @param params [ParamPackage] for an input device retrieved from [getInputDevices] + * @return Whether the device is a controller or not. + */ + fun isController(params: ParamPackage): Boolean = isControllerImpl(params.serialize()) + + private external fun isControllerImpl(params: String): Boolean + + /** + * Checks if a controller is connected + * @param playerIndex Index of the player to check. + * @return Whether the player is connected or not. + */ + external fun getIsConnected(playerIndex: Int): Boolean + + /** + * Connects/disconnects a controller and ensures that connection order stays in-tact. + * @param playerIndex Index of the player to connect/disconnect. + * @param connected Whether to connect or disconnect this controller. + */ + fun connectControllers(playerIndex: Int, connected: Boolean = true) { + val connectedControllers = mutableListOf().apply { + if (connected) { + for (i in 0 until 8) { + add(i <= playerIndex) + } + } else { + for (i in 0 until 8) { + add(i < playerIndex) + } + } + } + connectControllersImpl(connectedControllers.toBooleanArray()) + } + + private external fun connectControllersImpl(connected: BooleanArray) + + /** + * Resets all of the button and analog mappings for a player. + * @param playerIndex Index of the player that will have its mappings reset. + */ + external fun resetControllerMappings(playerIndex: Int) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt new file mode 100644 index 000000000..15cc38c7f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input + +import android.view.InputDevice +import androidx.annotation.Keep +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.utils.InputHandler.getGUID + +@Keep +interface YuzuInputDevice { + fun getName(): String + + fun getGUID(): String + + fun getPort(): Int + + fun getSupportsVibration(): Boolean + + fun vibrate(intensity: Float) + + fun getAxes(): Array = arrayOf() + fun hasKeys(keys: IntArray): BooleanArray = BooleanArray(0) +} + +class YuzuPhysicalDevice( + private val device: InputDevice, + private val port: Int, + useSystemVibrator: Boolean +) : YuzuInputDevice { + private val vibrator = if (useSystemVibrator) { + YuzuVibrator.getSystemVibrator() + } else { + YuzuVibrator.getControllerVibrator(device) + } + + override fun getName(): String { + return device.name + } + + override fun getGUID(): String { + return device.getGUID() + } + + override fun getPort(): Int { + return port + } + + override fun getSupportsVibration(): Boolean { + return vibrator.supportsVibration() + } + + override fun vibrate(intensity: Float) { + vibrator.vibrate(intensity) + } + + override fun getAxes(): Array = device.motionRanges.map { it.axis }.toTypedArray() + override fun hasKeys(keys: IntArray): BooleanArray = device.hasKeys(*keys) +} + +class YuzuInputOverlayDevice( + private val vibration: Boolean, + private val port: Int +) : YuzuInputDevice { + private val vibrator = YuzuVibrator.getSystemVibrator() + + override fun getName(): String { + return YuzuApplication.appContext.getString(R.string.input_overlay) + } + + override fun getGUID(): String { + return "00000000000000000000000000000000" + } + + override fun getPort(): Int { + return port + } + + override fun getSupportsVibration(): Boolean { + if (vibration) { + return vibrator.supportsVibration() + } + return false + } + + override fun vibrate(intensity: Float) { + if (vibration) { + vibrator.vibrate(intensity) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt new file mode 100644 index 000000000..aac49ecae --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input + +import android.content.Context +import android.os.Build +import android.os.CombinedVibration +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.InputDevice +import androidx.annotation.Keep +import androidx.annotation.RequiresApi +import org.yuzu.yuzu_emu.YuzuApplication + +@Keep +@Suppress("DEPRECATION") +interface YuzuVibrator { + fun supportsVibration(): Boolean + + fun vibrate(intensity: Float) + + companion object { + fun getControllerVibrator(device: InputDevice): YuzuVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + YuzuVibratorManager(device.vibratorManager) + } else { + YuzuVibratorManagerCompat(device.vibrator) + } + + fun getSystemVibrator(): YuzuVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = YuzuApplication.appContext + .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + YuzuVibratorManager(vibratorManager) + } else { + val vibrator = YuzuApplication.appContext + .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + YuzuVibratorManagerCompat(vibrator) + } + + fun getVibrationEffect(intensity: Float): VibrationEffect? { + if (intensity > 0f) { + return VibrationEffect.createOneShot( + 50, + (255.0 * intensity).toInt().coerceIn(1, 255) + ) + } + return null + } + } +} + +@RequiresApi(Build.VERSION_CODES.S) +class YuzuVibratorManager(private val vibratorManager: VibratorManager) : YuzuVibrator { + override fun supportsVibration(): Boolean { + return vibratorManager.vibratorIds.isNotEmpty() + } + + override fun vibrate(intensity: Float) { + val vibration = YuzuVibrator.getVibrationEffect(intensity) ?: return + vibratorManager.vibrate(CombinedVibration.createParallel(vibration)) + } +} + +class YuzuVibratorManagerCompat(private val vibrator: Vibrator) : YuzuVibrator { + override fun supportsVibration(): Boolean { + return vibrator.hasVibrator() + } + + override fun vibrate(intensity: Float) { + val vibration = YuzuVibrator.getVibrationEffect(intensity) ?: return + vibrator.vibrate(vibration) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt new file mode 100644 index 000000000..0a5fab2ae --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +enum class AnalogDirection(val int: Int, val param: String) { + Up(0, "up"), + Down(1, "down"), + Left(2, "left"), + Right(3, "right") +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt new file mode 100644 index 000000000..b8846ecad --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Loosely matches the enum in common/input.h +enum class ButtonName(val int: Int) { + Invalid(1), + + // This will display the engine name instead of the button name + Engine(2), + + // This will display the button by value instead of the button name + Value(3); + + companion object { + fun from(int: Int): ButtonName = entries.firstOrNull { it.int == int } ?: Invalid + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt new file mode 100644 index 000000000..f725231cb --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match the corresponding enum in input_common/main.h +enum class InputType(val int: Int) { + None(0), + Button(1), + Stick(2), + Motion(3), + Touch(4) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt new file mode 100644 index 000000000..c3b7a785d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeAnalog(val int: Int) { + LStick(0), + RStick(1); + + companion object { + fun from(int: Int): NativeAnalog = entries.firstOrNull { it.int == int } ?: LStick + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt new file mode 100644 index 000000000..c5ccd7115 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeButton(val int: Int) { + A(0), + B(1), + X(2), + Y(3), + LStick(4), + RStick(5), + L(6), + R(7), + ZL(8), + ZR(9), + Plus(10), + Minus(11), + + DLeft(12), + DUp(13), + DRight(14), + DDown(15), + + SLLeft(16), + SRLeft(17), + + Home(18), + Capture(19), + + SLRight(20), + SRRight(21); + + companion object { + fun from(int: Int): NativeButton = entries.firstOrNull { it.int == int } ?: A + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt new file mode 100644 index 000000000..625f352b4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeTrigger(val int: Int) { + LTrigger(0), + RTrigger(1) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt new file mode 100644 index 000000000..e2a3d7aff --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.R + +// Must match enum in src/core/hid/hid_types.h +enum class NpadStyleIndex(val int: Int, @StringRes val nameId: Int = 0) { + None(0), + Fullkey(3, R.string.pro_controller), + Handheld(4, R.string.handheld), + HandheldNES(4), + JoyconDual(5, R.string.dual_joycons), + JoyconLeft(6, R.string.left_joycon), + JoyconRight(7, R.string.right_joycon), + GameCube(8, R.string.gamecube_controller), + Pokeball(9), + NES(10), + SNES(12), + N64(13), + SegaGenesis(14), + SystemExt(32), + System(33); + + companion object { + fun from(int: Int): NpadStyleIndex = entries.firstOrNull { it.int == int } ?: None + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt new file mode 100644 index 000000000..d35de80c4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +import androidx.annotation.Keep + +@Keep +data class PlayerInput( + var connected: Boolean, + var buttons: Array, + var analogs: Array, + var motions: Array, + + var vibrationEnabled: Boolean, + var vibrationStrength: Int, + + var bodyColorLeft: Long, + var bodyColorRight: Long, + var buttonColorLeft: Long, + var buttonColorRight: Long, + var profileName: String, + + var useSystemVibrator: Boolean +) { + // It's recommended to use the generated equals() and hashCode() methods + // when using arrays in a data class + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerInput + + if (connected != other.connected) return false + if (!buttons.contentEquals(other.buttons)) return false + if (!analogs.contentEquals(other.analogs)) return false + if (!motions.contentEquals(other.motions)) return false + if (vibrationEnabled != other.vibrationEnabled) return false + if (vibrationStrength != other.vibrationStrength) return false + if (bodyColorLeft != other.bodyColorLeft) return false + if (bodyColorRight != other.bodyColorRight) return false + if (buttonColorLeft != other.buttonColorLeft) return false + if (buttonColorRight != other.buttonColorRight) return false + if (profileName != other.profileName) return false + return useSystemVibrator == other.useSystemVibrator + } + + override fun hashCode(): Int { + var result = connected.hashCode() + result = 31 * result + buttons.contentHashCode() + result = 31 * result + analogs.contentHashCode() + result = 31 * result + motions.contentHashCode() + result = 31 * result + vibrationEnabled.hashCode() + result = 31 * result + vibrationStrength + result = 31 * result + bodyColorLeft.hashCode() + result = 31 * result + bodyColorRight.hashCode() + result = 31 * result + buttonColorLeft.hashCode() + result = 31 * result + buttonColorRight.hashCode() + result = 31 * result + profileName.hashCode() + result = 31 * result + useSystemVibrator.hashCode() + return result + } + + fun hasMapping(): Boolean { + var hasMapping = false + buttons.forEach { + if (it != "[empty]") { + hasMapping = true + } + } + analogs.forEach { + if (it != "[empty]") { + hasMapping = true + } + } + motions.forEach { + if (it != "[empty]") { + hasMapping = true + } + } + return hasMapping + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt index 862c6c483..4f6b93bd2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt @@ -4,17 +4,30 @@ package org.yuzu.yuzu_emu.features.settings.model import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication object Settings { - enum class MenuTag(val titleId: Int) { + enum class MenuTag(val titleId: Int = 0) { SECTION_ROOT(R.string.advanced_settings), SECTION_SYSTEM(R.string.preferences_system), SECTION_RENDERER(R.string.preferences_graphics), SECTION_AUDIO(R.string.preferences_audio), + SECTION_INPUT(R.string.preferences_controls), + SECTION_INPUT_PLAYER_ONE, + SECTION_INPUT_PLAYER_TWO, + SECTION_INPUT_PLAYER_THREE, + SECTION_INPUT_PLAYER_FOUR, + SECTION_INPUT_PLAYER_FIVE, + SECTION_INPUT_PLAYER_SIX, + SECTION_INPUT_PLAYER_SEVEN, + SECTION_INPUT_PLAYER_EIGHT, SECTION_THEME(R.string.preferences_theme), SECTION_DEBUG(R.string.preferences_debug); } + fun getPlayerString(player: Int): String = + YuzuApplication.appContext.getString(R.string.preferences_player, player) + const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown" diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt new file mode 100644 index 000000000..a2996725e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.utils.ParamPackage + +class AnalogInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + val analogDirection: AnalogDirection, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val type = TYPE_INPUT + override val inputType = InputType.Stick + + override fun getSelectedValue(): String { + val params = NativeInput.getStickParam(playerIndex, nativeAnalog) + val analog = analogToText(params, analogDirection.param) + return getDisplayString(params, analog) + } + + override fun setSelectedValue(param: ParamPackage) = + NativeInput.setStickParam(playerIndex, nativeAnalog, param) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt new file mode 100644 index 000000000..786d09a7a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.utils.ParamPackage +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.NativeButton + +class ButtonInputSetting( + override val playerIndex: Int, + val nativeButton: NativeButton, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val type = TYPE_INPUT + override val inputType = InputType.Button + + override fun getSelectedValue(): String { + val params = NativeInput.getButtonParam(playerIndex, nativeButton) + val button = buttonToText(params) + return getDisplayString(params, button) + } + + override fun setSelectedValue(param: ParamPackage) = + NativeInput.setButtonParam(playerIndex, nativeButton, param) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt new file mode 100644 index 000000000..c46de08c5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.utils.NativeConfig + +class InputProfileSetting(private val playerIndex: Int) : + SettingsItem(emptySetting, R.string.profile, "", 0, "") { + override val type = TYPE_INPUT_PROFILE + + fun getCurrentProfile(): String = + NativeConfig.getInputSettings(true)[playerIndex].profileName + + fun getProfileNames(): Array = NativeInput.getInputProfileNames() + + fun isProfileNameValid(name: String): Boolean = NativeInput.isProfileNameValid(name) + + fun createProfile(name: String): Boolean = NativeInput.createProfile(name, playerIndex) + + fun deleteProfile(name: String): Boolean = NativeInput.deleteProfile(name, playerIndex) + + fun loadProfile(name: String): Boolean { + val result = NativeInput.loadProfile(name, playerIndex) + NativeInput.reloadInputDevices() + return result + } + + fun saveProfile(name: String): Boolean = NativeInput.saveProfile(name, playerIndex) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt new file mode 100644 index 000000000..2d118bff3 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.ButtonName +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.utils.ParamPackage + +sealed class InputSetting( + @StringRes titleId: Int, + titleString: String +) : SettingsItem(emptySetting, titleId, titleString, 0, "") { + override val type = TYPE_INPUT + abstract val inputType: InputType + abstract val playerIndex: Int + + protected val context get() = YuzuApplication.appContext + + abstract fun getSelectedValue(): String + + abstract fun setSelectedValue(param: ParamPackage) + + protected fun getDisplayString(params: ParamPackage, control: String): String { + val deviceName = params.get("display", "") + deviceName.ifEmpty { + return context.getString(R.string.not_set) + } + return "$deviceName: $control" + } + + private fun getDirectionName(direction: String): String = + when (direction) { + "up" -> context.getString(R.string.up) + "down" -> context.getString(R.string.down) + "left" -> context.getString(R.string.left) + "right" -> context.getString(R.string.right) + else -> direction + } + + protected fun buttonToText(param: ParamPackage): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + val toggle = if (param.get("toggle", false)) "~" else "" + val inverted = if (param.get("inverted", false)) "!" else "" + val invert = if (param.get("invert", "+") == "-") "-" else "" + val turbo = if (param.get("turbo", false)) "$" else "" + val commonButtonName = NativeInput.getButtonName(param) + + if (commonButtonName == ButtonName.Invalid) { + return context.getString(R.string.invalid) + } + + if (commonButtonName == ButtonName.Engine) { + return param.get("engine", "") + } + + if (commonButtonName == ButtonName.Value) { + if (param.has("hat")) { + val hat = getDirectionName(param.get("direction", "")) + return context.getString(R.string.qualified_hat, turbo, toggle, inverted, hat) + } + if (param.has("axis")) { + val axis = param.get("axis", "") + return context.getString( + R.string.qualified_button_stick_axis, + toggle, + inverted, + invert, + axis + ) + } + if (param.has("button")) { + val button = param.get("button", "") + return context.getString(R.string.qualified_button, turbo, toggle, inverted, button) + } + } + + return context.getString(R.string.unknown) + } + + protected fun analogToText(param: ParamPackage, direction: String): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + if (param.get("engine", "") == "analog_from_button") { + return buttonToText(ParamPackage(param.get(direction, ""))) + } + + if (!param.has("axis_x") || !param.has("axis_y")) { + return context.getString(R.string.unknown) + } + + val xAxis = param.get("axis_x", "") + val yAxis = param.get("axis_y", "") + val xInvert = param.get("invert_x", "+") == "-" + val yInvert = param.get("invert_y", "+") == "-" + + if (direction == "modifier") { + return context.getString(R.string.unused) + } + + when (direction) { + "up" -> { + val yInvertString = if (yInvert) "+" else "-" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "down" -> { + val yInvertString = if (yInvert) "-" else "+" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "left" -> { + val xInvertString = if (xInvert) "+" else "-" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + + "right" -> { + val xInvertString = if (xInvert) "-" else "+" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + } + + return context.getString(R.string.unknown) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt new file mode 100644 index 000000000..e024c793a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting + +class IntSingleChoiceSetting( + private val intSetting: AbstractIntSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val choices: Array, + val values: Array +) : SettingsItem(intSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_INT_SINGLE_CHOICE + + fun getValueAt(index: Int): Int = + if (values.indices.contains(index)) values[index] else -1 + + fun getChoiceAt(index: Int): String = + if (choices.indices.contains(index)) choices[index] else "" + + fun getSelectedValue(needsGlobal: Boolean = false) = intSetting.getInt(needsGlobal) + fun setSelectedValue(value: Int) = intSetting.setInt(value) + + val selectedValueIndex: Int + get() { + for (i in values.indices) { + if (values[i] == getSelectedValue()) { + return i + } + } + return -1 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt new file mode 100644 index 000000000..a1db3cc87 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.utils.ParamPackage + +class ModifierInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val inputType = InputType.Button + + override fun getSelectedValue(): String { + val analogParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + val modifierParam = ParamPackage(analogParam.get("modifier", "")) + return buttonToText(modifierParam) + } + + override fun setSelectedValue(param: ParamPackage) { + val newParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + newParam.set("modifier", param.serialize()) + NativeInput.setStickParam(playerIndex, nativeAnalog, newParam) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt index 1005a2b7d..06f607424 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt @@ -7,11 +7,11 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes class RunnableSetting( - val isRuntimeRunnable: Boolean, @StringRes titleId: Int = 0, titleString: String = "", @StringRes descriptionId: Int = 0, descriptionString: String = "", + val isRunnable: Boolean, @DrawableRes val iconId: Int = 0, val runnable: () -> Unit ) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt new file mode 100644 index 000000000..16a1d0504 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.app.Dialog +import android.graphics.drawable.Animatable2 +import android.graphics.drawable.AnimatedVectorDrawable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogMappingBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.InputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ModifierInputSetting +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.ParamPackage + +class InputDialogFragment : DialogFragment() { + private var inputAccepted = false + + private var position: Int = 0 + + private lateinit var inputSetting: InputSetting + + private lateinit var binding: DialogMappingBinding + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (settingsViewModel.clickedItem == null) dismiss() + + position = requireArguments().getInt(POSITION) + + InputHandler.updateControllerData() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + inputSetting = settingsViewModel.clickedItem as InputSetting + binding = DialogMappingBinding.inflate(layoutInflater) + + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(android.R.string.cancel) { _, _ -> + NativeInput.stopMapping() + dismiss() + } + .setView(binding.root) + + val playButtonMapAnimation = { twoDirections: Boolean -> + val stickAnimation: AnimatedVectorDrawable + val buttonAnimation: AnimatedVectorDrawable + binding.imageStickAnimation.apply { + val anim = if (twoDirections) { + R.drawable.stick_two_direction_anim + } else { + R.drawable.stick_one_direction_anim + } + setBackgroundResource(anim) + stickAnimation = background as AnimatedVectorDrawable + } + binding.imageButtonAnimation.apply { + setBackgroundResource(R.drawable.button_anim) + buttonAnimation = background as AnimatedVectorDrawable + } + stickAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + buttonAnimation.start() + } + }) + buttonAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + stickAnimation.start() + } + }) + stickAnimation.start() + } + + when (val setting = inputSetting) { + is AnalogInputSetting -> { + when (setting.nativeAnalog) { + NativeAnalog.LStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.left_stick)) + ) + + NativeAnalog.RStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.right_stick)) + ) + } + + builder.setMessage(R.string.stick_map_description) + + playButtonMapAnimation.invoke(true) + } + + is ModifierInputSetting -> { + builder.setTitle(getString(R.string.map_control, setting.title)) + .setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + + is ButtonInputSetting -> { + if (setting.nativeButton == NativeButton.DUp || + setting.nativeButton == NativeButton.DDown || + setting.nativeButton == NativeButton.DLeft || + setting.nativeButton == NativeButton.DRight + ) { + builder.setTitle(getString(R.string.map_dpad_direction, setting.title)) + } else { + builder.setTitle(getString(R.string.map_control, setting.title)) + } + builder.setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + } + + return builder.create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.requestFocus() + view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } + dialog?.setOnKeyListener { _, _, keyEvent -> onKeyEvent(keyEvent) } + binding.root.setOnGenericMotionListener { _, motionEvent -> onMotionEvent(motionEvent) } + NativeInput.beginMapping(inputSetting.inputType.int) + } + + private fun onKeyEvent(event: KeyEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED + else -> return false + } + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, + action + ) + onInputReceived(event.device) + return true + } + + private fun onMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + // Temp workaround for DPads that give both axis and button input. The input system can't + // take in a specific axis direction for a binding so you lose half of the directions for a DPad. + + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) + onInputReceived(event.device) + } + return true + } + + private fun onInputReceived(device: InputDevice) { + val params = ParamPackage(NativeInput.getNextInput()) + if (params.has("engine") && isInputAcceptable(params) && !inputAccepted) { + inputAccepted = true + setResult(params, device) + } + } + + private fun setResult(params: ParamPackage, device: InputDevice) { + NativeInput.stopMapping() + params.set("display", "${device.name} ${params.get("port", 0)}") + when (val item = settingsViewModel.clickedItem as InputSetting) { + is ModifierInputSetting, + is ButtonInputSetting -> { + // Invert DPad up and left bindings by default + val tempSetting = inputSetting as? ButtonInputSetting + if (tempSetting != null) { + if (tempSetting.nativeButton == NativeButton.DUp || + tempSetting.nativeButton == NativeButton.DLeft && + params.has("axis") + ) { + params.set("invert", "-") + } + } + + item.setSelectedValue(params) + settingsViewModel.setAdapterItemChanged(position) + } + + is AnalogInputSetting -> { + var analogParam = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + analogParam = adjustAnalogParam(params, analogParam, item.analogDirection.param) + + // Invert Y-Axis by default + analogParam.set("invert_y", "-") + + item.setSelectedValue(analogParam) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + } + dismiss() + } + + private fun adjustAnalogParam( + inputParam: ParamPackage, + analogParam: ParamPackage, + buttonName: String + ): ParamPackage { + // The poller returned a complete axis, so set all the buttons + if (inputParam.has("axis_x") && inputParam.has("axis_y")) { + return inputParam + } + + // Check if the current configuration has either no engine or an axis binding. + // Clears out the old binding and adds one with analog_from_button. + if (!analogParam.has("engine") || analogParam.has("axis_x") || analogParam.has("axis_y")) { + analogParam.clear() + analogParam.set("engine", "analog_from_button") + } + analogParam.set(buttonName, inputParam.serialize()) + return analogParam + } + + private fun isInputAcceptable(params: ParamPackage): Boolean { + if (InputHandler.registeredControllers.size == 1) { + return true + } + + if (params.has("motion")) { + return true + } + + val currentDevice = settingsViewModel.getCurrentDeviceParams(params) + if (currentDevice.get("engine", "any") == "any") { + return true + } + + val guidMatch = params.get("guid", "") == currentDevice.get("guid", "") || + params.get("guid", "") == currentDevice.get("guid2", "") + return params.get("engine", "") == currentDevice.get("engine", "") && + guidMatch && + params.get("port", 0) == currentDevice.get("port", 0) + } + + companion object { + const val TAG = "InputDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + inputMappingViewModel: SettingsViewModel, + setting: InputSetting, + position: Int + ): InputDialogFragment { + inputMappingViewModel.clickedItem = setting + val args = Bundle() + args.putInt(POSITION, position) + val fragment = InputDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt new file mode 100644 index 000000000..5656e9d8d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.AbstractListAdapter +import org.yuzu.yuzu_emu.databinding.ListItemInputProfileBinding +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder +import org.yuzu.yuzu_emu.R + +class InputProfileAdapter(options: List) : + AbstractListAdapter>(options) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AbstractViewHolder { + ListItemInputProfileBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return InputProfileViewHolder(it) } + } + + inner class InputProfileViewHolder(val binding: ListItemInputProfileBinding) : + AbstractViewHolder(binding) { + override fun bind(model: ProfileItem) { + when (model) { + is ExistingProfileItem -> { + binding.title.text = model.name + binding.buttonNew.visibility = View.GONE + binding.buttonDelete.visibility = View.VISIBLE + binding.buttonDelete.setOnClickListener { model.deleteProfile.invoke() } + binding.buttonSave.visibility = View.VISIBLE + binding.buttonSave.setOnClickListener { model.saveProfile.invoke() } + binding.buttonLoad.visibility = View.VISIBLE + binding.buttonLoad.setOnClickListener { model.loadProfile.invoke() } + } + + is NewProfileItem -> { + binding.title.text = model.name + binding.buttonNew.visibility = View.VISIBLE + binding.buttonNew.setOnClickListener { model.createNewProfile.invoke() } + binding.buttonSave.visibility = View.GONE + binding.buttonDelete.visibility = View.GONE + binding.buttonLoad.visibility = View.GONE + } + } + } + } +} + +sealed interface ProfileItem { + val name: String +} + +data class NewProfileItem( + val createNewProfile: () -> Unit +) : ProfileItem { + override val name: String = YuzuApplication.appContext.getString(R.string.create_new_profile) +} + +data class ExistingProfileItem( + override val name: String, + val deleteProfile: () -> Unit, + val saveProfile: () -> Unit, + val loadProfile: () -> Unit +) : ProfileItem diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt new file mode 100644 index 000000000..9b24d41c1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogInputProfilesBinding +import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment + +class InputProfileDialogFragment : DialogFragment() { + private var position = 0 + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var binding: DialogInputProfilesBinding + + private lateinit var setting: InputProfileSetting + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + position = requireArguments().getInt(POSITION) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogInputProfilesBinding.inflate(layoutInflater) + + setting = settingsViewModel.clickedItem as InputProfileSetting + val options = mutableListOf().apply { + add( + NewProfileItem( + createNewProfile = { + NewInputProfileDialogFragment.newInstance( + settingsViewModel, + setting, + position + ).show(parentFragmentManager, NewInputProfileDialogFragment.TAG) + dismiss() + } + ) + ) + + val onActionDismiss = { + settingsViewModel.setReloadListAndNotifyDataset(true) + dismiss() + } + setting.getProfileNames().forEach { + add( + ExistingProfileItem( + it, + deleteProfile = { + settingsViewModel.setShouldShowDeleteProfileDialog(it) + }, + saveProfile = { + if (!setting.saveProfile(it)) { + Toast.makeText( + requireContext(), + R.string.failed_to_save_profile, + Toast.LENGTH_SHORT + ).show() + } + onActionDismiss.invoke() + }, + loadProfile = { + if (!setting.loadProfile(it)) { + Toast.makeText( + requireContext(), + R.string.failed_to_load_profile, + Toast.LENGTH_SHORT + ).show() + } + onActionDismiss.invoke() + } + ) + ) + } + } + binding.listProfiles.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = InputProfileAdapter(options) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + settingsViewModel.shouldShowDeleteProfileDialog.collect { + if (it.isNotEmpty()) { + MessageDialogFragment.newInstance( + activity = requireActivity(), + titleId = R.string.delete_input_profile, + descriptionId = R.string.delete_input_profile_description, + positiveAction = { + setting.deleteProfile(it) + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + negativeAction = {}, + negativeButtonTitleId = android.R.string.cancel + ).show(parentFragmentManager, MessageDialogFragment.TAG) + settingsViewModel.setShouldShowDeleteProfileDialog("") + dismiss() + } + } + } + } + } + + companion object { + const val TAG = "InputProfileDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + profileSetting: InputProfileSetting, + position: Int + ): InputProfileDialogFragment { + settingsViewModel.clickedItem = profileSetting + + val args = Bundle() + args.putInt(POSITION, position) + val fragment = InputProfileDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt new file mode 100644 index 000000000..6e52bea80 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.app.Dialog +import android.os.Bundle +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding +import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting +import org.yuzu.yuzu_emu.R + +class NewInputProfileDialogFragment : DialogFragment() { + private var position = 0 + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var binding: DialogEditTextBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + position = requireArguments().getInt(POSITION) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogEditTextBinding.inflate(layoutInflater) + + val setting = settingsViewModel.clickedItem as InputProfileSetting + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.enter_profile_name) + .setPositiveButton(android.R.string.ok) { _, _ -> + val profileName = binding.editText.text.toString() + if (!setting.isProfileNameValid(profileName)) { + Toast.makeText( + requireContext(), + R.string.invalid_profile_name, + Toast.LENGTH_SHORT + ).show() + return@setPositiveButton + } + + if (!setting.createProfile(profileName)) { + Toast.makeText( + requireContext(), + R.string.profile_name_already_exists, + Toast.LENGTH_SHORT + ).show() + } else { + settingsViewModel.setAdapterItemChanged(position) + } + } + .setNegativeButton(android.R.string.cancel, null) + .setView(binding.root) + .show() + } + + companion object { + const val TAG = "NewInputProfileDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + profileSetting: InputProfileSetting, + position: Int + ): NewInputProfileDialogFragment { + settingsViewModel.clickedItem = profileSetting + + val args = Bundle() + args.putInt(POSITION, position) + val fragment = NewInputProfileDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt index 6f072241a..681a18b3b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt @@ -25,9 +25,9 @@ import org.yuzu.yuzu_emu.NativeLibrary import java.io.IOException import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding +import org.yuzu.yuzu_emu.features.input.NativeInput import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment -import org.yuzu.yuzu_emu.model.SettingsViewModel import org.yuzu.yuzu_emu.utils.* class SettingsActivity : AppCompatActivity() { @@ -137,6 +137,7 @@ class SettingsActivity : AppCompatActivity() { super.onStop() Log.info("[SettingsActivity] Settings activity stopping. Saving settings to INI...") if (isFinishing) { + NativeInput.reloadInputDevices() NativeLibrary.applySettings() if (args.game == null) { NativeConfig.saveGlobalConfig() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt index be9b3031b..45c8faa10 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt @@ -8,12 +8,11 @@ import android.icu.util.Calendar import android.icu.util.TimeZone import android.text.format.DateFormat import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.PopupMenu import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.DiffUtil @@ -21,16 +20,18 @@ import androidx.recyclerview.widget.ListAdapter import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.timepicker.MaterialTimePicker import com.google.android.material.timepicker.TimeFormat -import kotlinx.coroutines.launch import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.SettingsNavigationDirections import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting import org.yuzu.yuzu_emu.features.settings.model.view.* import org.yuzu.yuzu_emu.features.settings.ui.viewholder.* -import org.yuzu.yuzu_emu.fragments.SettingsDialogFragment -import org.yuzu.yuzu_emu.model.SettingsViewModel +import org.yuzu.yuzu_emu.utils.ParamPackage class SettingsAdapter( private val fragment: Fragment, @@ -41,19 +42,6 @@ class SettingsAdapter( private val settingsViewModel: SettingsViewModel get() = ViewModelProvider(fragment.requireActivity())[SettingsViewModel::class.java] - init { - fragment.viewLifecycleOwner.lifecycleScope.launch { - fragment.repeatOnLifecycle(Lifecycle.State.STARTED) { - settingsViewModel.adapterItemChanged.collect { - if (it != -1) { - notifyItemChanged(it) - settingsViewModel.setAdapterItemChanged(-1) - } - } - } - } - } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { @@ -85,8 +73,19 @@ class SettingsAdapter( RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this) } + SettingsItem.TYPE_INPUT -> { + InputViewHolder(ListItemSettingInputBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INT_SINGLE_CHOICE -> { + SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INPUT_PROFILE -> { + InputProfileViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + else -> { - // TODO: Create an error view since we can't return null now HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) } } @@ -126,6 +125,15 @@ class SettingsAdapter( ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) } + fun onIntSingleChoiceClick(item: IntSingleChoiceSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_INT_SINGLE_CHOICE, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + fun onDateTimeClick(item: DateTimeSetting, position: Int) { val storedTime = item.getValue() * 1000 @@ -185,6 +193,205 @@ class SettingsAdapter( fragment.view?.findNavController()?.navigate(action) } + fun onInputProfileClick(item: InputProfileSetting, position: Int) { + InputProfileDialogFragment.newInstance( + settingsViewModel, + item, + position + ).show(fragment.childFragmentManager, InputProfileDialogFragment.TAG) + } + + fun onInputClick(item: InputSetting, position: Int) { + InputDialogFragment.newInstance( + settingsViewModel, + item, + position + ).show(fragment.childFragmentManager, InputDialogFragment.TAG) + } + + fun onInputOptionsClick(anchor: View, item: InputSetting, position: Int) { + val popup = PopupMenu(context, anchor) + popup.menuInflater.inflate(R.menu.menu_input_options, popup.menu) + + popup.menu.apply { + val invertAxis = findItem(R.id.invert_axis) + val invertButton = findItem(R.id.invert_button) + val toggleButton = findItem(R.id.toggle_button) + val turboButton = findItem(R.id.turbo_button) + val setThreshold = findItem(R.id.set_threshold) + val toggleAxis = findItem(R.id.toggle_axis) + when (item) { + is AnalogInputSetting -> { + val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + + invertAxis.isVisible = true + invertAxis.isCheckable = true + invertAxis.isChecked = when (item.analogDirection) { + AnalogDirection.Left, AnalogDirection.Right -> { + params.get("invert_x", "+") == "-" + } + + AnalogDirection.Up, AnalogDirection.Down -> { + params.get("invert_y", "+") == "-" + } + } + invertAxis.setOnMenuItemClickListener { + if (item.analogDirection == AnalogDirection.Left || + item.analogDirection == AnalogDirection.Right + ) { + val invertValue = params.get("invert_x", "+") == "-" + val invertString = if (invertValue) "+" else "-" + params.set("invert_x", invertString) + } else if ( + item.analogDirection == AnalogDirection.Up || + item.analogDirection == AnalogDirection.Down + ) { + val invertValue = params.get("invert_y", "+") == "-" + val invertString = if (invertValue) "+" else "-" + params.set("invert_y", invertString) + } + true + } + + popup.setOnDismissListener { + NativeInput.setStickParam(item.playerIndex, item.nativeAnalog, params) + settingsViewModel.setDatasetChanged(true) + } + } + + is ButtonInputSetting -> { + val params = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) + if (params.has("code") || params.has("button") || params.has("hat")) { + val buttonInvert = params.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = buttonInvert + invertButton.setOnMenuItemClickListener { + params.set("inverted", !buttonInvert) + true + } + + val toggle = params.get("toggle", false) + toggleButton.isVisible = true + toggleButton.isCheckable = true + toggleButton.isChecked = toggle + toggleButton.setOnMenuItemClickListener { + params.set("toggle", !toggle) + true + } + + val turbo = params.get("turbo", false) + turboButton.isVisible = true + turboButton.isCheckable = true + turboButton.isChecked = turbo + turboButton.setOnMenuItemClickListener { + params.set("turbo", !turbo) + true + } + } else if (params.has("axis")) { + val axisInvert = params.get("invert", "+") == "-" + invertAxis.isVisible = true + invertAxis.isCheckable = true + invertAxis.isChecked = axisInvert + invertAxis.setOnMenuItemClickListener { + params.set("invert", if (!axisInvert) "-" else "+") + true + } + + val buttonInvert = params.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = buttonInvert + invertButton.setOnMenuItemClickListener { + params.set("inverted", !buttonInvert) + true + } + + setThreshold.isVisible = true + val thresholdSetting = object : AbstractIntSetting { + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + (params.get("threshold", 0.5f) * 100).toInt() + + override fun setInt(value: Int) { + params.set("threshold", value.toFloat() / 100) + NativeInput.setButtonParam( + item.playerIndex, + item.nativeButton, + params + ) + } + + override val defaultValue = 50 + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + } + setThreshold.setOnMenuItemClickListener { + onSliderClick( + SliderSetting(thresholdSetting, R.string.set_threshold), + position + ) + true + } + + val axisToggle = params.get("toggle", false) + toggleAxis.isVisible = true + toggleAxis.isCheckable = true + toggleAxis.isChecked = axisToggle + toggleAxis.setOnMenuItemClickListener { + params.set("toggle", !axisToggle) + true + } + } + + popup.setOnDismissListener { + NativeInput.setButtonParam(item.playerIndex, item.nativeButton, params) + settingsViewModel.setAdapterItemChanged(position) + } + } + + is ModifierInputSetting -> { + val stickParams = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + val modifierParams = ParamPackage(stickParams.get("modifier", "")) + + val invert = modifierParams.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = invert + invertButton.setOnMenuItemClickListener { + modifierParams.set("inverted", !invert) + stickParams.set("modifier", modifierParams.serialize()) + true + } + + val toggle = modifierParams.get("toggle", false) + toggleButton.isVisible = true + toggleButton.isCheckable = true + toggleButton.isChecked = toggle + toggleButton.setOnMenuItemClickListener { + modifierParams.set("toggle", !toggle) + stickParams.set("modifier", modifierParams.serialize()) + true + } + + popup.setOnDismissListener { + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + stickParams + ) + settingsViewModel.setAdapterItemChanged(position) + } + } + } + } + popup.show() + } + fun onLongClick(item: SettingsItem, position: Int): Boolean { SettingsDialogFragment.newInstance( settingsViewModel, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt new file mode 100644 index 000000000..5d1ea5d29 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogSliderBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.IntSingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting +import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting +import org.yuzu.yuzu_emu.utils.ParamPackage + +class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener { + private var type = 0 + private var position = 0 + + private var defaultCancelListener = + DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var sliderBinding: DialogSliderBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + type = requireArguments().getInt(TYPE) + position = requireArguments().getInt(POSITION) + + if (settingsViewModel.clickedItem == null) dismiss() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return when (type) { + TYPE_RESET_SETTING -> { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.reset_setting_confirmation) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + when (val item = settingsViewModel.clickedItem) { + is AnalogInputSetting -> { + val stickParam = NativeInput.getStickParam( + item.playerIndex, + item.nativeAnalog + ) + if (stickParam.get("engine", "") == "analog_from_button") { + when (item.analogDirection) { + AnalogDirection.Up -> stickParam.erase("up") + AnalogDirection.Down -> stickParam.erase("down") + AnalogDirection.Left -> stickParam.erase("left") + AnalogDirection.Right -> stickParam.erase("right") + } + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + stickParam + ) + settingsViewModel.setAdapterItemChanged(position) + } else { + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + ParamPackage() + ) + settingsViewModel.setDatasetChanged(true) + } + } + + is ButtonInputSetting -> { + NativeInput.setButtonParam( + item.playerIndex, + item.nativeButton, + ParamPackage() + ) + settingsViewModel.setAdapterItemChanged(position) + } + + else -> { + settingsViewModel.clickedItem!!.setting.reset() + settingsViewModel.setAdapterItemChanged(position) + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + SettingsItem.TYPE_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as SingleChoiceSetting + val value = getSelectionForSingleChoiceValue(item) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choicesId, value, this) + .create() + } + + SettingsItem.TYPE_SLIDER -> { + sliderBinding = DialogSliderBinding.inflate(layoutInflater) + val item = settingsViewModel.clickedItem as SliderSetting + + settingsViewModel.setSliderTextValue(item.getSelectedValue().toFloat(), item.units) + sliderBinding.slider.apply { + valueFrom = item.min.toFloat() + valueTo = item.max.toFloat() + value = settingsViewModel.sliderProgress.value.toFloat() + addOnChangeListener { _: Slider, value: Float, _: Boolean -> + settingsViewModel.setSliderTextValue(value, item.units) + } + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setView(sliderBinding.root) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, defaultCancelListener) + .create() + } + + SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as StringSingleChoiceSetting + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) + .create() + } + + SettingsItem.TYPE_INT_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as IntSingleChoiceSetting + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) + .create() + } + + else -> super.onCreateDialog(savedInstanceState) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return when (type) { + SettingsItem.TYPE_SLIDER -> sliderBinding.root + else -> super.onCreateView(inflater, container, savedInstanceState) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + when (type) { + SettingsItem.TYPE_SLIDER -> { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + settingsViewModel.sliderTextValue.collect { + sliderBinding.textValue.text = it + } + } + repeatOnLifecycle(Lifecycle.State.CREATED) { + settingsViewModel.sliderProgress.collect { + sliderBinding.slider.value = it.toFloat() + } + } + } + } + } + } + + override fun onClick(dialog: DialogInterface, which: Int) { + when (settingsViewModel.clickedItem) { + is SingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting + val value = getValueForSingleChoiceSelection(scSetting, which) + scSetting.setSelectedValue(value) + } + + is StringSingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting + val value = scSetting.getValueAt(which) + scSetting.setSelectedValue(value) + } + + is IntSingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as IntSingleChoiceSetting + val value = scSetting.getValueAt(which) + scSetting.setSelectedValue(value) + } + + is SliderSetting -> { + val sliderSetting = settingsViewModel.clickedItem as SliderSetting + sliderSetting.setSelectedValue(settingsViewModel.sliderProgress.value) + } + } + closeDialog() + } + + private fun closeDialog() { + settingsViewModel.setAdapterItemChanged(position) + settingsViewModel.clickedItem = null + settingsViewModel.setSliderProgress(-1f) + dismiss() + } + + private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { + val valuesId = item.valuesId + return if (valuesId > 0) { + val valuesArray = requireContext().resources.getIntArray(valuesId) + valuesArray[which] + } else { + which + } + } + + private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { + val value = item.getSelectedValue() + val valuesId = item.valuesId + if (valuesId > 0) { + val valuesArray = requireContext().resources.getIntArray(valuesId) + for (index in valuesArray.indices) { + val current = valuesArray[index] + if (current == value) { + return index + } + } + } else { + return value + } + return -1 + } + + companion object { + const val TAG = "SettingsDialogFragment" + + const val TYPE_RESET_SETTING = -1 + + const val TITLE = "Title" + const val TYPE = "Type" + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + clickedItem: SettingsItem, + type: Int, + position: Int + ): SettingsDialogFragment { + when (type) { + SettingsItem.TYPE_HEADER, + SettingsItem.TYPE_SWITCH, + SettingsItem.TYPE_SUBMENU, + SettingsItem.TYPE_DATETIME_SETTING, + SettingsItem.TYPE_RUNNABLE -> + throw IllegalArgumentException("[SettingsDialogFragment] Incompatible type!") + + SettingsItem.TYPE_SLIDER -> settingsViewModel.setSliderProgress( + (clickedItem as SliderSetting).getSelectedValue().toFloat() + ) + } + settingsViewModel.clickedItem = clickedItem + + val args = Bundle() + args.putInt(TYPE, type) + args.putInt(POSITION, position) + val fragment = SettingsDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt index 6f6e7be10..0cf944b43 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt @@ -24,8 +24,9 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding +import org.yuzu.yuzu_emu.features.input.NativeInput import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.model.SettingsViewModel +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins class SettingsFragment : Fragment() { @@ -45,6 +46,12 @@ class SettingsFragment : Fragment() { returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + + val playerIndex = getPlayerIndex() + if (playerIndex != -1) { + NativeInput.loadInputProfiles() + NativeInput.reloadInputDevices() + } } override fun onCreateView( @@ -57,8 +64,9 @@ class SettingsFragment : Fragment() { } // This is using the correct scope, lint is just acting up - @SuppressLint("UnsafeRepeatOnLifecycleDetector") + @SuppressLint("UnsafeRepeatOnLifecycleDetector", "NotifyDataSetChanged") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) settingsAdapter = SettingsAdapter(this, requireContext()) presenter = SettingsFragmentPresenter( settingsViewModel, @@ -71,7 +79,17 @@ class SettingsFragment : Fragment() { ) { args.game!!.title } else { - getString(args.menuTag.titleId) + when (args.menuTag) { + Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> Settings.getPlayerString(1) + Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> Settings.getPlayerString(2) + Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> Settings.getPlayerString(3) + Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> Settings.getPlayerString(4) + Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> Settings.getPlayerString(5) + Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> Settings.getPlayerString(6) + Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> Settings.getPlayerString(7) + Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> Settings.getPlayerString(8) + else -> getString(args.menuTag.titleId) + } } binding.listSettings.apply { adapter = settingsAdapter @@ -93,6 +111,55 @@ class SettingsFragment : Fragment() { } } } + launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + settingsViewModel.adapterItemChanged.collect { + if (it != -1) { + settingsAdapter?.notifyItemChanged(it) + settingsViewModel.setAdapterItemChanged(-1) + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + settingsViewModel.datasetChanged.collect { + if (it) { + settingsAdapter?.notifyDataSetChanged() + settingsViewModel.setDatasetChanged(false) + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + settingsViewModel.reloadListAndNotifyDataset.collectLatest { + if (it) { + settingsViewModel.setReloadListAndNotifyDataset(false) + presenter.loadSettingsList(true) + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + settingsViewModel.shouldShowResetInputDialog.collectLatest { + if (it) { + MessageDialogFragment.newInstance( + activity = requireActivity(), + titleId = R.string.reset_mapping, + descriptionId = R.string.reset_mapping_description, + positiveAction = { + NativeInput.resetControllerMappings(getPlayerIndex()) + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + negativeAction = {} + ).show(parentFragmentManager, MessageDialogFragment.TAG) + settingsViewModel.setShouldShowResetInputDialog(false) + } + } + } + } } if (args.menuTag == Settings.MenuTag.SECTION_ROOT) { @@ -115,6 +182,19 @@ class SettingsFragment : Fragment() { setInsets() } + private fun getPlayerIndex(): Int = + when (args.menuTag) { + Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> 0 + Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> 1 + Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> 2 + Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> 3 + Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> 4 + Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> 5 + Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> 6 + Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> 7 + else -> -1 + } + private fun setInsets() { ViewCompat.setOnApplyWindowInsetsListener( binding.root diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index 5d495a7ca..e491c29a2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -3,11 +3,17 @@ package org.yuzu.yuzu_emu.features.settings.ui +import android.annotation.SuppressLint import android.os.Build import android.widget.Toast import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting @@ -15,18 +21,21 @@ import org.yuzu.yuzu_emu.features.settings.model.ByteSetting import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.features.settings.model.LongSetting import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.Settings.MenuTag import org.yuzu.yuzu_emu.features.settings.model.ShortSetting import org.yuzu.yuzu_emu.features.settings.model.view.* -import org.yuzu.yuzu_emu.model.SettingsViewModel +import org.yuzu.yuzu_emu.utils.InputHandler import org.yuzu.yuzu_emu.utils.NativeConfig class SettingsFragmentPresenter( private val settingsViewModel: SettingsViewModel, private val adapter: SettingsAdapter, - private var menuTag: Settings.MenuTag + private var menuTag: MenuTag ) { private var settingsList = ArrayList() + private val context get() = YuzuApplication.appContext + // Extension for altering settings list based on each setting's properties fun ArrayList.add(key: String) { val item = SettingsItem.settingsItems[key]!! @@ -53,31 +62,48 @@ class SettingsFragmentPresenter( add(item) } + // Allows you to show/hide abstract settings based on the paired setting key + fun ArrayList.addAbstract(item: SettingsItem) { + val pairedSettingKey = item.setting.pairedSettingKey + if (pairedSettingKey.isNotEmpty()) { + val pairedSettingsItem = + this.firstOrNull { it.setting.key == pairedSettingKey } ?: return + val pairedSetting = pairedSettingsItem.setting as AbstractBooleanSetting + if (!pairedSetting.getBoolean(!NativeConfig.isPerGameConfigLoaded())) return + } + add(item) + } + fun onViewCreated() { loadSettingsList() } - fun loadSettingsList() { + @SuppressLint("NotifyDataSetChanged") + fun loadSettingsList(notifyDataSetChanged: Boolean = false) { val sl = ArrayList() when (menuTag) { - Settings.MenuTag.SECTION_ROOT -> addConfigSettings(sl) - Settings.MenuTag.SECTION_SYSTEM -> addSystemSettings(sl) - Settings.MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl) - Settings.MenuTag.SECTION_AUDIO -> addAudioSettings(sl) - Settings.MenuTag.SECTION_THEME -> addThemeSettings(sl) - Settings.MenuTag.SECTION_DEBUG -> addDebugSettings(sl) - else -> { - val context = YuzuApplication.appContext - Toast.makeText( - context, - context.getString(R.string.unimplemented_menu), - Toast.LENGTH_SHORT - ).show() - return - } + MenuTag.SECTION_ROOT -> addConfigSettings(sl) + MenuTag.SECTION_SYSTEM -> addSystemSettings(sl) + MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl) + MenuTag.SECTION_AUDIO -> addAudioSettings(sl) + MenuTag.SECTION_INPUT -> addInputSettings(sl) + MenuTag.SECTION_INPUT_PLAYER_ONE -> addInputPlayer(sl, 0) + MenuTag.SECTION_INPUT_PLAYER_TWO -> addInputPlayer(sl, 1) + MenuTag.SECTION_INPUT_PLAYER_THREE -> addInputPlayer(sl, 2) + MenuTag.SECTION_INPUT_PLAYER_FOUR -> addInputPlayer(sl, 3) + MenuTag.SECTION_INPUT_PLAYER_FIVE -> addInputPlayer(sl, 4) + MenuTag.SECTION_INPUT_PLAYER_SIX -> addInputPlayer(sl, 5) + MenuTag.SECTION_INPUT_PLAYER_SEVEN -> addInputPlayer(sl, 6) + MenuTag.SECTION_INPUT_PLAYER_EIGHT -> addInputPlayer(sl, 7) + MenuTag.SECTION_THEME -> addThemeSettings(sl) + MenuTag.SECTION_DEBUG -> addDebugSettings(sl) } settingsList = sl - adapter.submitList(settingsList) + adapter.submitList(settingsList) { + if (notifyDataSetChanged) { + adapter.notifyDataSetChanged() + } + } } private fun addConfigSettings(sl: ArrayList) { @@ -118,6 +144,7 @@ class SettingsFragmentPresenter( RunnableSetting( titleId = R.string.reset_to_default, descriptionId = R.string.reset_to_default_description, + isRunnable = !NativeLibrary.isRunning(), iconId = R.drawable.ic_restore ) { settingsViewModel.setShouldShowResetSettingsDialog(true) } ) @@ -163,6 +190,671 @@ class SettingsFragmentPresenter( } } + private fun addInputSettings(sl: ArrayList) { + settingsViewModel.currentDevice = 0 + + if (NativeConfig.isPerGameConfigLoaded()) { + NativeInput.loadInputProfiles() + val profiles = NativeInput.getInputProfileNames().toMutableList() + profiles.add(0, "") + val prettyProfiles = profiles.toTypedArray() + prettyProfiles[0] = + context.getString(R.string.use_global_input_configuration) + sl.apply { + for (i in 0 until 8) { + add( + IntSingleChoiceSetting( + getPerGameProfileSetting(profiles, i), + titleString = getPlayerProfileString(i + 1), + choices = prettyProfiles, + values = IntArray(profiles.size) { it }.toTypedArray() + ) + ) + } + } + return + } + + val getConnectedIcon: (Int) -> Int = { playerIndex: Int -> + if (NativeInput.getIsConnected(playerIndex)) { + R.drawable.ic_controller + } else { + R.drawable.ic_controller_disconnected + } + } + + val inputSettings = NativeConfig.getInputSettings(true) + sl.apply { + add( + SubmenuSetting( + titleString = Settings.getPlayerString(1), + descriptionString = inputSettings[0].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_ONE, + iconId = getConnectedIcon(0) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(2), + descriptionString = inputSettings[1].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_TWO, + iconId = getConnectedIcon(1) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(3), + descriptionString = inputSettings[2].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_THREE, + iconId = getConnectedIcon(2) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(4), + descriptionString = inputSettings[3].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FOUR, + iconId = getConnectedIcon(3) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(5), + descriptionString = inputSettings[4].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FIVE, + iconId = getConnectedIcon(4) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(6), + descriptionString = inputSettings[5].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SIX, + iconId = getConnectedIcon(5) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(7), + descriptionString = inputSettings[6].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SEVEN, + iconId = getConnectedIcon(6) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(8), + descriptionString = inputSettings[7].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_EIGHT, + iconId = getConnectedIcon(7) + ) + ) + } + } + + private fun getPlayerProfileString(player: Int): String = + context.getString(R.string.player_num_profile, player) + + private fun getPerGameProfileSetting( + profiles: List, + playerIndex: Int + ): AbstractIntSetting { + return object : AbstractIntSetting { + private val players + get() = NativeConfig.getInputSettings(false) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int { + val currentProfile = players[playerIndex].profileName + profiles.forEachIndexed { i, profile -> + if (profile == currentProfile) { + return i + } + } + return 0 + } + + override fun setInt(value: Int) { + NativeInput.loadPerGameConfiguration(playerIndex, value, profiles[value]) + NativeInput.connectControllers(playerIndex) + NativeConfig.saveControlPlayerValues() + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override var global = true + + override val isRuntimeModifiable = true + + override val isSaveable = true + } + } + + private fun addInputPlayer(sl: ArrayList, playerIndex: Int) { + sl.apply { + val connectedSetting = object : AbstractBooleanSetting { + override val key = "connected" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeInput.getIsConnected(playerIndex) + + override fun setBoolean(value: Boolean) = + NativeInput.connectControllers(playerIndex, value) + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(connectedSetting, R.string.connected)) + + val styleTags = NativeInput.getSupportedStyleTags(playerIndex) + val npadType = object : AbstractIntSetting { + override val key = "npad_type" + override fun getInt(needsGlobal: Boolean): Int { + val styleIndex = NativeInput.getStyleIndex(playerIndex) + return styleTags.indexOfFirst { it == styleIndex } + } + + override fun setInt(value: Int) { + NativeInput.setStyleIndex(playerIndex, styleTags[value]) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = NpadStyleIndex.Fullkey.int + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + override fun reset() = setInt(defaultValue) + override val pairedSettingKey: String = "connected" + } + addAbstract( + IntSingleChoiceSetting( + npadType, + titleId = R.string.controller_type, + choices = styleTags.map { context.getString(it.nameId) } + .toTypedArray(), + values = IntArray(styleTags.size) { it }.toTypedArray() + ) + ) + + InputHandler.updateControllerData() + + val autoMappingSetting = object : AbstractIntSetting { + override val key = "auto_mapping_device" + + override fun getInt(needsGlobal: Boolean): Int = -1 + + override fun setInt(value: Int) { + val registeredController = InputHandler.registeredControllers[value + 1] + val displayName = registeredController.get( + "display", + context.getString(R.string.unknown) + ) + NativeInput.updateMappingsWithDefault( + playerIndex, + registeredController, + displayName + ) + Toast.makeText( + context, + context.getString(R.string.attempted_auto_map, displayName), + Toast.LENGTH_SHORT + ).show() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = -1 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val unknownString = context.getString(R.string.unknown) + val prettyAutoMappingControllerList = InputHandler.registeredControllers.mapNotNull { + val port = it.get("port", -1) + return@mapNotNull if (port == 100 || port == -1) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + autoMappingSetting, + titleId = R.string.auto_map, + descriptionId = R.string.auto_map_description, + choices = prettyAutoMappingControllerList, + values = IntArray(prettyAutoMappingControllerList.size) { it }.toTypedArray() + ) + ) + + val mappingFilterSetting = object : AbstractIntSetting { + override val key = "mapping_filter" + + override fun getInt(needsGlobal: Boolean): Int = settingsViewModel.currentDevice + + override fun setInt(value: Int) { + settingsViewModel.currentDevice = value + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val prettyControllerList = InputHandler.registeredControllers.mapNotNull { + return@mapNotNull if (it.get("port", 0) == 100) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + mappingFilterSetting, + titleId = R.string.input_mapping_filter, + descriptionId = R.string.input_mapping_filter_description, + choices = prettyControllerList, + values = IntArray(prettyControllerList.size) { it }.toTypedArray() + ) + ) + + add(InputProfileSetting(playerIndex)) + add( + RunnableSetting(titleId = R.string.reset_to_default, isRunnable = true) { + settingsViewModel.setShouldShowResetInputDialog(true) + } + ) + + val styleIndex = NativeInput.getStyleIndex(playerIndex) + + // Buttons + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.start_pause)) + } + + else -> { + // No-op + } + } + + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.dpad)) + add(ButtonInputSetting(playerIndex, NativeButton.DUp, R.string.up)) + add(ButtonInputSetting(playerIndex, NativeButton.DDown, R.string.down)) + add(ButtonInputSetting(playerIndex, NativeButton.DLeft, R.string.left)) + add(ButtonInputSetting(playerIndex, NativeButton.DRight, R.string.right)) + } + + else -> { + // No-op + } + } + + // Left stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.left_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + add(ButtonInputSetting(playerIndex, NativeButton.LStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.control_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + else -> { + // No-op + } + } + + // Right stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.right_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + add(ButtonInputSetting(playerIndex, NativeButton.RStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.c_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + else -> { + // No-op + } + } + + // L/R, ZL/ZR, and SL/SR + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + } + + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_z)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_r)) + } + + else -> { + // No-op + } + } + + add(HeaderSetting(R.string.vibration)) + val vibrationEnabledSetting = object : AbstractBooleanSetting { + override val key = "vibration" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].vibrationEnabled + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationEnabled = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = true + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(vibrationEnabledSetting, R.string.vibration)) + + val useSystemVibratorSetting = object : AbstractBooleanSetting { + override val key = "" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].useSystemVibrator + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].useSystemVibrator = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract(SwitchSetting(useSystemVibratorSetting, R.string.use_system_vibrator)) + + val vibrationStrengthSetting = object : AbstractIntSetting { + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + NativeConfig.getInputSettings(true)[playerIndex].vibrationStrength + + override fun setInt(value: Int) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationStrength = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = 100 + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract( + SliderSetting(vibrationStrengthSetting, R.string.vibration_strength, units = "%") + ) + } + } + + // Convenience function for creating AbstractIntSettings for modifier range/stick range/stick deadzones + private fun getStickIntSettingFromParam( + playerIndex: Int, + paramName: String, + stick: NativeAnalog, + defaultValue: Int + ): AbstractIntSetting = + object : AbstractIntSetting { + val params get() = NativeInput.getStickParam(playerIndex, stick) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + (params.get(paramName, 0.15f) * 100).toInt() + + override fun setInt(value: Int) { + val tempParams = params + tempParams.set(paramName, value.toFloat() / 100) + NativeInput.setStickParam(playerIndex, stick, tempParams) + } + + override val defaultValue = defaultValue + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + } + + private fun getExtraStickSettings( + playerIndex: Int, + nativeAnalog: NativeAnalog + ): List { + val stickIsController = + NativeInput.isController(NativeInput.getStickParam(playerIndex, nativeAnalog)) + val modifierRangeSetting = + getStickIntSettingFromParam(playerIndex, "modifier_scale", nativeAnalog, 50) + val stickRangeSetting = + getStickIntSettingFromParam(playerIndex, "range", nativeAnalog, 95) + val stickDeadzoneSetting = + getStickIntSettingFromParam(playerIndex, "deadzone", nativeAnalog, 15) + + val out = mutableListOf().apply { + if (stickIsController) { + add(SliderSetting(stickRangeSetting, titleId = R.string.range, min = 25, max = 150)) + add(SliderSetting(stickDeadzoneSetting, R.string.deadzone)) + } else { + add(ModifierInputSetting(playerIndex, NativeAnalog.LStick, R.string.modifier)) + add(SliderSetting(modifierRangeSetting, R.string.modifier_range)) + } + } + return out + } + + private fun getStickDirections(player: Int, stick: NativeAnalog): List = + listOf( + AnalogInputSetting( + player, + stick, + AnalogDirection.Up, + R.string.up + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Down, + R.string.down + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Left, + R.string.left + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Right, + R.string.right + ) + ) + private fun addThemeSettings(sl: ArrayList) { sl.apply { val theme: AbstractIntSetting = object : AbstractIntSetting { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt new file mode 100644 index 000000000..51740a2ac --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import com.google.android.material.transition.MaterialSharedAxis +import info.debatty.java.stringsimilarity.Cosine +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentSettingsSearchBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins + +class SettingsSearchFragment : Fragment() { + private var _binding: FragmentSettingsSearchBinding? = null + private val binding get() = _binding!! + + private var settingsAdapter: SettingsAdapter? = null + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsSearchBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + settingsAdapter = SettingsAdapter(this, requireContext()) + + val dividerDecoration = MaterialDividerItemDecoration( + requireContext(), + LinearLayoutManager.VERTICAL + ) + dividerDecoration.isLastItemDecorated = false + binding.settingsList.apply { + adapter = settingsAdapter + layoutManager = LinearLayoutManager(requireContext()) + addItemDecoration(dividerDecoration) + } + + focusSearch() + + binding.backButton.setOnClickListener { settingsViewModel.setShouldNavigateBack(true) } + binding.searchBackground.setOnClickListener { focusSearch() } + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + binding.searchText.doOnTextChanged { _, _, _, _ -> + search() + binding.settingsList.smoothScrollToPosition(0) + } + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + settingsViewModel.shouldReloadSettingsList.collect { + if (it) { + settingsViewModel.setShouldReloadSettingsList(false) + search() + } + } + } + } + + search() + + setInsets() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + + private fun search() { + val searchTerm = binding.searchText.text.toString().lowercase() + binding.clearButton.visibility = + if (searchTerm.isEmpty()) View.INVISIBLE else View.VISIBLE + if (searchTerm.isEmpty()) { + binding.noResultsView.visibility = View.VISIBLE + settingsAdapter?.submitList(emptyList()) + return + } + + val baseList = SettingsItem.settingsItems + val similarityAlgorithm = if (searchTerm.length > 2) Cosine() else Cosine(1) + val sortedList: List = baseList.mapNotNull { item -> + val title = item.value.title.lowercase() + val similarity = similarityAlgorithm.similarity(searchTerm, title) + if (similarity > 0.08) { + Pair(similarity, item) + } else { + null + } + }.sortedByDescending { it.first }.mapNotNull { + val item = it.second.value + val pairedSettingKey = item.setting.pairedSettingKey + val optionalSetting: SettingsItem? = if (pairedSettingKey.isNotEmpty()) { + val pairedSettingValue = NativeConfig.getBoolean(pairedSettingKey, false) + if (pairedSettingValue) it.second.value else null + } else { + it.second.value + } + optionalSetting + } + settingsAdapter?.submitList(sortedList) + binding.noResultsView.visibility = + if (sortedList.isEmpty()) View.VISIBLE else View.INVISIBLE + } + + private fun focusSearch() { + binding.searchText.requestFocus() + val imm = requireActivity() + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val sideMargin = resources.getDimensionPixelSize(R.dimen.spacing_medlarge) + val topMargin = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.settingsList.updatePadding(bottom = barInsets.bottom + extraListSpacing) + binding.frameSearch.updatePadding( + left = leftInsets + sideMargin, + top = barInsets.top + topMargin, + right = rightInsets + sideMargin + ) + binding.noResultsView.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + binding.settingsList.updateMargins( + left = leftInsets + sideMargin, + right = rightInsets + sideMargin + ) + binding.divider.updateMargins( + left = leftInsets + sideMargin, + right = rightInsets + sideMargin + ) + + windowInsets + } + + companion object { + const val SEARCH_TEXT = "SearchText" + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt new file mode 100644 index 000000000..fbdca04e9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.ParamPackage + +class SettingsViewModel : ViewModel() { + var game: Game? = null + + var clickedItem: SettingsItem? = null + + var currentDevice = 0 + + val shouldRecreate: StateFlow get() = _shouldRecreate + private val _shouldRecreate = MutableStateFlow(false) + + val shouldNavigateBack: StateFlow get() = _shouldNavigateBack + private val _shouldNavigateBack = MutableStateFlow(false) + + val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog + private val _shouldShowResetSettingsDialog = MutableStateFlow(false) + + val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList + private val _shouldReloadSettingsList = MutableStateFlow(false) + + val sliderProgress: StateFlow get() = _sliderProgress + private val _sliderProgress = MutableStateFlow(-1) + + val sliderTextValue: StateFlow get() = _sliderTextValue + private val _sliderTextValue = MutableStateFlow("") + + val adapterItemChanged: StateFlow get() = _adapterItemChanged + private val _adapterItemChanged = MutableStateFlow(-1) + + private val _datasetChanged = MutableStateFlow(false) + val datasetChanged = _datasetChanged.asStateFlow() + + private val _reloadListAndNotifyDataset = MutableStateFlow(false) + val reloadListAndNotifyDataset = _reloadListAndNotifyDataset.asStateFlow() + + private val _shouldShowDeleteProfileDialog = MutableStateFlow("") + val shouldShowDeleteProfileDialog = _shouldShowDeleteProfileDialog.asStateFlow() + + private val _shouldShowResetInputDialog = MutableStateFlow(false) + val shouldShowResetInputDialog = _shouldShowResetInputDialog.asStateFlow() + + fun setShouldRecreate(value: Boolean) { + _shouldRecreate.value = value + } + + fun setShouldNavigateBack(value: Boolean) { + _shouldNavigateBack.value = value + } + + fun setShouldShowResetSettingsDialog(value: Boolean) { + _shouldShowResetSettingsDialog.value = value + } + + fun setShouldReloadSettingsList(value: Boolean) { + _shouldReloadSettingsList.value = value + } + + fun setSliderTextValue(value: Float, units: String) { + _sliderProgress.value = value.toInt() + _sliderTextValue.value = String.format( + YuzuApplication.appContext.getString(R.string.value_with_units), + value.toInt().toString(), + units + ) + } + + fun setSliderProgress(value: Float) { + _sliderProgress.value = value.toInt() + } + + fun setAdapterItemChanged(value: Int) { + _adapterItemChanged.value = value + } + + fun setDatasetChanged(value: Boolean) { + _datasetChanged.value = value + } + + fun setReloadListAndNotifyDataset(value: Boolean) { + _reloadListAndNotifyDataset.value = value + } + + fun setShouldShowDeleteProfileDialog(profile: String) { + _shouldShowDeleteProfileDialog.value = profile + } + + fun setShouldShowResetInputDialog(value: Boolean) { + _shouldShowResetInputDialog.value = value + } + + fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage = + try { + InputHandler.registeredControllers[currentDevice] + } catch (e: IndexOutOfBoundsException) { + defaultParams + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt new file mode 100644 index 000000000..81161d5d3 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.R + +class InputProfileViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: InputProfileSetting + + override fun bind(item: SettingsItem) { + setting = item as InputProfileSetting + binding.textSettingName.text = setting.title + binding.textSettingValue.text = + setting.getCurrentProfile().ifEmpty { binding.root.context.getString(R.string.not_set) } + + binding.textSettingDescription.visibility = View.GONE + binding.buttonClear.visibility = View.GONE + binding.icon.visibility = View.GONE + binding.buttonClear.visibility = View.GONE + } + + override fun onClick(clicked: View) = + adapter.onInputProfileClick(setting, bindingAdapterPosition) + + override fun onLongClick(clicked: View): Boolean = false +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt new file mode 100644 index 000000000..1f1f08190 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.InputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ModifierInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class InputViewHolder(val binding: ListItemSettingInputBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: InputSetting + + override fun bind(item: SettingsItem) { + setting = item as InputSetting + binding.textSettingName.text = setting.title + binding.textSettingValue.text = setting.getSelectedValue() + + binding.buttonOptions.visibility = when (item) { + is AnalogInputSetting -> { + val param = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + if ( + param.get("engine", "") == "analog_from_button" || + param.has("axis_x") || param.has("axis_y") + ) { + View.VISIBLE + } else { + View.GONE + } + } + + is ButtonInputSetting -> { + val param = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) + if ( + param.has("code") || param.has("button") || param.has("hat") || + param.has("axis") + ) { + View.VISIBLE + } else { + View.GONE + } + } + + is ModifierInputSetting -> { + val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + if (params.has("modifier")) { + View.VISIBLE + } else { + View.GONE + } + } + } + + binding.buttonOptions.setOnClickListener(null) + binding.buttonOptions.setOnClickListener { + adapter.onInputOptionsClick(binding.buttonOptions, setting, bindingAdapterPosition) + } + } + + override fun onClick(clicked: View) = + adapter.onInputClick(setting, bindingAdapterPosition) + + override fun onLongClick(clicked: View): Boolean = + adapter.onLongClick(setting, bindingAdapterPosition) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt index 2cecede48..9705d428c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt @@ -47,6 +47,9 @@ class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: Setti binding.textSettingValue.text = item.getChoiceAt(item.getSelectedValue()) } } + if (binding.textSettingValue.text.isEmpty()) { + binding.textSettingValue.visibility = View.GONE + } binding.buttonClear.visibility = if (setting.setting.global || !NativeConfig.isPerGameConfigLoaded() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 6b25cc525..c737ed5e8 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -277,6 +277,15 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { true } + R.id.menu_controls -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + true + } + R.id.menu_overlay_controls -> { showOverlayOptions() true diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 87e130d3e..14a2504b6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -89,6 +89,20 @@ class HomeSettingsFragment : Fragment() { } ) ) + add( + HomeSetting( + R.string.preferences_controls, + R.string.preferences_controls_description, + R.drawable.ic_controller, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + } + ) + ) add( HomeSetting( R.string.gpu_driver_manager, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt deleted file mode 100644 index 60e029f34..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt +++ /dev/null @@ -1,227 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.slider.Slider -import kotlinx.coroutines.launch -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogSliderBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting -import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting -import org.yuzu.yuzu_emu.model.SettingsViewModel - -class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener { - private var type = 0 - private var position = 0 - - private var defaultCancelListener = - DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } - - private val settingsViewModel: SettingsViewModel by activityViewModels() - - private lateinit var sliderBinding: DialogSliderBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - type = requireArguments().getInt(TYPE) - position = requireArguments().getInt(POSITION) - - if (settingsViewModel.clickedItem == null) dismiss() - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return when (type) { - TYPE_RESET_SETTING -> { - MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.reset_setting_confirmation) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - settingsViewModel.clickedItem!!.setting.reset() - settingsViewModel.setAdapterItemChanged(position) - } - .setNegativeButton(android.R.string.cancel, null) - .create() - } - - SettingsItem.TYPE_SINGLE_CHOICE -> { - val item = settingsViewModel.clickedItem as SingleChoiceSetting - val value = getSelectionForSingleChoiceValue(item) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.nameId) - .setSingleChoiceItems(item.choicesId, value, this) - .create() - } - - SettingsItem.TYPE_SLIDER -> { - sliderBinding = DialogSliderBinding.inflate(layoutInflater) - val item = settingsViewModel.clickedItem as SliderSetting - - settingsViewModel.setSliderTextValue(item.getSelectedValue().toFloat(), item.units) - sliderBinding.slider.apply { - valueFrom = item.min.toFloat() - valueTo = item.max.toFloat() - value = settingsViewModel.sliderProgress.value.toFloat() - addOnChangeListener { _: Slider, value: Float, _: Boolean -> - settingsViewModel.setSliderTextValue(value, item.units) - } - } - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.nameId) - .setView(sliderBinding.root) - .setPositiveButton(android.R.string.ok, this) - .setNegativeButton(android.R.string.cancel, defaultCancelListener) - .create() - } - - SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { - val item = settingsViewModel.clickedItem as StringSingleChoiceSetting - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.nameId) - .setSingleChoiceItems(item.choices, item.selectValueIndex, this) - .create() - } - - else -> super.onCreateDialog(savedInstanceState) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return when (type) { - SettingsItem.TYPE_SLIDER -> sliderBinding.root - else -> super.onCreateView(inflater, container, savedInstanceState) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - when (type) { - SettingsItem.TYPE_SLIDER -> { - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - settingsViewModel.sliderTextValue.collect { - sliderBinding.textValue.text = it - } - } - repeatOnLifecycle(Lifecycle.State.CREATED) { - settingsViewModel.sliderProgress.collect { - sliderBinding.slider.value = it.toFloat() - } - } - } - } - } - } - - override fun onClick(dialog: DialogInterface, which: Int) { - when (settingsViewModel.clickedItem) { - is SingleChoiceSetting -> { - val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting - val value = getValueForSingleChoiceSelection(scSetting, which) - scSetting.setSelectedValue(value) - } - - is StringSingleChoiceSetting -> { - val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting - val value = scSetting.getValueAt(which) - scSetting.setSelectedValue(value) - } - - is SliderSetting -> { - val sliderSetting = settingsViewModel.clickedItem as SliderSetting - sliderSetting.setSelectedValue(settingsViewModel.sliderProgress.value) - } - } - closeDialog() - } - - private fun closeDialog() { - settingsViewModel.setAdapterItemChanged(position) - settingsViewModel.clickedItem = null - settingsViewModel.setSliderProgress(-1f) - dismiss() - } - - private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { - val valuesId = item.valuesId - return if (valuesId > 0) { - val valuesArray = requireContext().resources.getIntArray(valuesId) - valuesArray[which] - } else { - which - } - } - - private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { - val value = item.getSelectedValue() - val valuesId = item.valuesId - if (valuesId > 0) { - val valuesArray = requireContext().resources.getIntArray(valuesId) - for (index in valuesArray.indices) { - val current = valuesArray[index] - if (current == value) { - return index - } - } - } else { - return value - } - return -1 - } - - companion object { - const val TAG = "SettingsDialogFragment" - - const val TYPE_RESET_SETTING = -1 - - const val TITLE = "Title" - const val TYPE = "Type" - const val POSITION = "Position" - - fun newInstance( - settingsViewModel: SettingsViewModel, - clickedItem: SettingsItem, - type: Int, - position: Int - ): SettingsDialogFragment { - when (type) { - SettingsItem.TYPE_HEADER, - SettingsItem.TYPE_SWITCH, - SettingsItem.TYPE_SUBMENU, - SettingsItem.TYPE_DATETIME_SETTING, - SettingsItem.TYPE_RUNNABLE -> - throw IllegalArgumentException("[SettingsDialogFragment] Incompatible type!") - - SettingsItem.TYPE_SLIDER -> settingsViewModel.setSliderProgress( - (clickedItem as SliderSetting).getSelectedValue().toFloat() - ) - } - settingsViewModel.clickedItem = clickedItem - - val args = Bundle() - args.putInt(TYPE, type) - args.putInt(POSITION, position) - val fragment = SettingsDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsSearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsSearchFragment.kt deleted file mode 100644 index a135b80b4..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsSearchFragment.kt +++ /dev/null @@ -1,193 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.divider.MaterialDividerItemDecoration -import com.google.android.material.transition.MaterialSharedAxis -import info.debatty.java.stringsimilarity.Cosine -import kotlinx.coroutines.launch -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.FragmentSettingsSearchBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.model.SettingsViewModel -import org.yuzu.yuzu_emu.utils.NativeConfig -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins - -class SettingsSearchFragment : Fragment() { - private var _binding: FragmentSettingsSearchBinding? = null - private val binding get() = _binding!! - - private var settingsAdapter: SettingsAdapter? = null - - private val settingsViewModel: SettingsViewModel by activityViewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSettingsSearchBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - if (savedInstanceState != null) { - binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) - } - - settingsAdapter = SettingsAdapter(this, requireContext()) - - val dividerDecoration = MaterialDividerItemDecoration( - requireContext(), - LinearLayoutManager.VERTICAL - ) - dividerDecoration.isLastItemDecorated = false - binding.settingsList.apply { - adapter = settingsAdapter - layoutManager = LinearLayoutManager(requireContext()) - addItemDecoration(dividerDecoration) - } - - focusSearch() - - binding.backButton.setOnClickListener { settingsViewModel.setShouldNavigateBack(true) } - binding.searchBackground.setOnClickListener { focusSearch() } - binding.clearButton.setOnClickListener { binding.searchText.setText("") } - binding.searchText.doOnTextChanged { _, _, _, _ -> - search() - binding.settingsList.smoothScrollToPosition(0) - } - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - settingsViewModel.shouldReloadSettingsList.collect { - if (it) { - settingsViewModel.setShouldReloadSettingsList(false) - search() - } - } - } - } - - search() - - setInsets() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) - } - - private fun search() { - val searchTerm = binding.searchText.text.toString().lowercase() - binding.clearButton.visibility = - if (searchTerm.isEmpty()) View.INVISIBLE else View.VISIBLE - if (searchTerm.isEmpty()) { - binding.noResultsView.visibility = View.VISIBLE - settingsAdapter?.submitList(emptyList()) - return - } - - val baseList = SettingsItem.settingsItems - val similarityAlgorithm = if (searchTerm.length > 2) Cosine() else Cosine(1) - val sortedList: List = baseList.mapNotNull { item -> - val title = getString(item.value.nameId).lowercase() - val similarity = similarityAlgorithm.similarity(searchTerm, title) - if (similarity > 0.08) { - Pair(similarity, item) - } else { - null - } - }.sortedByDescending { it.first }.mapNotNull { - val item = it.second.value - val pairedSettingKey = item.setting.pairedSettingKey - val optionalSetting: SettingsItem? = if (pairedSettingKey.isNotEmpty()) { - val pairedSettingValue = NativeConfig.getBoolean(pairedSettingKey, false) - if (pairedSettingValue) it.second.value else null - } else { - it.second.value - } - optionalSetting - } - settingsAdapter?.submitList(sortedList) - binding.noResultsView.visibility = - if (sortedList.isEmpty()) View.VISIBLE else View.INVISIBLE - } - - private fun focusSearch() { - binding.searchText.requestFocus() - val imm = requireActivity() - .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? - imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) - val sideMargin = resources.getDimensionPixelSize(R.dimen.spacing_medlarge) - val topMargin = resources.getDimensionPixelSize(R.dimen.spacing_chip) - - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.settingsList.updatePadding(bottom = barInsets.bottom + extraListSpacing) - binding.frameSearch.updatePadding( - left = leftInsets + sideMargin, - top = barInsets.top + topMargin, - right = rightInsets + sideMargin - ) - binding.noResultsView.updatePadding( - left = leftInsets, - right = rightInsets, - bottom = barInsets.bottom - ) - - binding.settingsList.updateMargins( - left = leftInsets + sideMargin, - right = rightInsets + sideMargin - ) - binding.divider.updateMargins( - left = leftInsets + sideMargin, - right = rightInsets + sideMargin - ) - - windowInsets - } - - companion object { - const val SEARCH_TEXT = "SearchText" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt deleted file mode 100644 index 5cb6a5d57..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem - -class SettingsViewModel : ViewModel() { - var game: Game? = null - - var clickedItem: SettingsItem? = null - - val shouldRecreate: StateFlow get() = _shouldRecreate - private val _shouldRecreate = MutableStateFlow(false) - - val shouldNavigateBack: StateFlow get() = _shouldNavigateBack - private val _shouldNavigateBack = MutableStateFlow(false) - - val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog - private val _shouldShowResetSettingsDialog = MutableStateFlow(false) - - val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList - private val _shouldReloadSettingsList = MutableStateFlow(false) - - val sliderProgress: StateFlow get() = _sliderProgress - private val _sliderProgress = MutableStateFlow(-1) - - val sliderTextValue: StateFlow get() = _sliderTextValue - private val _sliderTextValue = MutableStateFlow("") - - val adapterItemChanged: StateFlow get() = _adapterItemChanged - private val _adapterItemChanged = MutableStateFlow(-1) - - fun setShouldRecreate(value: Boolean) { - _shouldRecreate.value = value - } - - fun setShouldNavigateBack(value: Boolean) { - _shouldNavigateBack.value = value - } - - fun setShouldShowResetSettingsDialog(value: Boolean) { - _shouldShowResetSettingsDialog.value = value - } - - fun setShouldReloadSettingsList(value: Boolean) { - _shouldReloadSettingsList.value = value - } - - fun setSliderTextValue(value: Float, units: String) { - _sliderProgress.value = value.toInt() - _sliderTextValue.value = String.format( - YuzuApplication.appContext.getString(R.string.value_with_units), - value.toInt().toString(), - units - ) - } - - fun setSliderProgress(value: Float) { - _sliderProgress.value = value.toInt() - } - - fun setAdapterItemChanged(value: Int) { - _adapterItemChanged.value = value - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt index c87486c90..66907085a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt @@ -24,10 +24,10 @@ import androidx.core.content.ContextCompat import androidx.window.layout.WindowMetricsCalculator import kotlin.math.max import kotlin.math.min -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.NativeLibrary.ButtonType -import org.yuzu.yuzu_emu.NativeLibrary.StickType +import org.yuzu.yuzu_emu.features.input.NativeInput import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.overlay.model.OverlayControl @@ -100,19 +100,19 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : var shouldUpdateView = false val playerIndex = - if (NativeLibrary.isHandheldOnly()) { - NativeLibrary.ConsoleDevice + if (NativeInput.isHandheldOnly()) { + NativeInput.ConsoleDevice } else { - NativeLibrary.Player1Device + NativeInput.Player1Device } for (button in overlayButtons) { if (!button.updateStatus(event)) { continue } - NativeLibrary.onGamePadButtonEvent( + NativeInput.onOverlayButtonEvent( playerIndex, - button.buttonId, + button.button, button.status ) playHaptics(event) @@ -123,24 +123,24 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : if (!dpad.updateStatus(event, BooleanSetting.DPAD_SLIDE.getBoolean())) { continue } - NativeLibrary.onGamePadButtonEvent( + NativeInput.onOverlayButtonEvent( playerIndex, - dpad.upId, + dpad.up, dpad.upStatus ) - NativeLibrary.onGamePadButtonEvent( + NativeInput.onOverlayButtonEvent( playerIndex, - dpad.downId, + dpad.down, dpad.downStatus ) - NativeLibrary.onGamePadButtonEvent( + NativeInput.onOverlayButtonEvent( playerIndex, - dpad.leftId, + dpad.left, dpad.leftStatus ) - NativeLibrary.onGamePadButtonEvent( + NativeInput.onOverlayButtonEvent( playerIndex, - dpad.rightId, + dpad.right, dpad.rightStatus ) playHaptics(event) @@ -151,16 +151,15 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : if (!joystick.updateStatus(event)) { continue } - val axisID = joystick.joystickId - NativeLibrary.onGamePadJoystickEvent( + NativeInput.onOverlayJoystickEvent( playerIndex, - axisID, + joystick.joystick, joystick.xAxis, joystick.realYAxis ) - NativeLibrary.onGamePadButtonEvent( + NativeInput.onOverlayButtonEvent( playerIndex, - joystick.buttonId, + joystick.button, joystick.buttonStatus ) playHaptics(event) @@ -187,7 +186,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP if (isActionDown && !isTouchInputConsumed(pointerId)) { - NativeLibrary.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat()) + NativeInput.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat()) } if (isActionMove) { @@ -196,12 +195,12 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : if (isTouchInputConsumed(fingerId)) { continue } - NativeLibrary.onTouchMoved(fingerId, event.getX(i), event.getY(i)) + NativeInput.onTouchMoved(fingerId, event.getX(i), event.getY(i)) } } if (isActionUp && !isTouchInputConsumed(pointerId)) { - NativeLibrary.onTouchReleased(pointerId) + NativeInput.onTouchReleased(pointerId) } return true @@ -359,7 +358,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_a, R.drawable.facebutton_a_depressed, - ButtonType.BUTTON_A, + NativeButton.A, data, position ) @@ -373,7 +372,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_b, R.drawable.facebutton_b_depressed, - ButtonType.BUTTON_B, + NativeButton.B, data, position ) @@ -387,7 +386,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_x, R.drawable.facebutton_x_depressed, - ButtonType.BUTTON_X, + NativeButton.X, data, position ) @@ -401,7 +400,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_y, R.drawable.facebutton_y_depressed, - ButtonType.BUTTON_Y, + NativeButton.Y, data, position ) @@ -415,7 +414,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_plus, R.drawable.facebutton_plus_depressed, - ButtonType.BUTTON_PLUS, + NativeButton.Plus, data, position ) @@ -429,7 +428,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_minus, R.drawable.facebutton_minus_depressed, - ButtonType.BUTTON_MINUS, + NativeButton.Minus, data, position ) @@ -443,7 +442,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_home, R.drawable.facebutton_home_depressed, - ButtonType.BUTTON_HOME, + NativeButton.Home, data, position ) @@ -457,7 +456,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.facebutton_screenshot, R.drawable.facebutton_screenshot_depressed, - ButtonType.BUTTON_CAPTURE, + NativeButton.Capture, data, position ) @@ -471,7 +470,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.l_shoulder, R.drawable.l_shoulder_depressed, - ButtonType.TRIGGER_L, + NativeButton.L, data, position ) @@ -485,7 +484,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.r_shoulder, R.drawable.r_shoulder_depressed, - ButtonType.TRIGGER_R, + NativeButton.R, data, position ) @@ -499,7 +498,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.zl_trigger, R.drawable.zl_trigger_depressed, - ButtonType.TRIGGER_ZL, + NativeButton.ZL, data, position ) @@ -513,7 +512,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.zr_trigger, R.drawable.zr_trigger_depressed, - ButtonType.TRIGGER_ZR, + NativeButton.ZR, data, position ) @@ -527,7 +526,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.button_l3, R.drawable.button_l3_depressed, - ButtonType.STICK_L, + NativeButton.LStick, data, position ) @@ -541,7 +540,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize, R.drawable.button_r3, R.drawable.button_r3_depressed, - ButtonType.STICK_R, + NativeButton.RStick, data, position ) @@ -556,8 +555,8 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : R.drawable.joystick_range, R.drawable.joystick, R.drawable.joystick_depressed, - StickType.STICK_L, - ButtonType.STICK_L, + NativeAnalog.LStick, + NativeButton.LStick, data, position ) @@ -572,8 +571,8 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : R.drawable.joystick_range, R.drawable.joystick, R.drawable.joystick_depressed, - StickType.STICK_R, - ButtonType.STICK_R, + NativeAnalog.RStick, + NativeButton.RStick, data, position ) @@ -835,7 +834,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : windowSize: Pair, defaultResId: Int, pressedResId: Int, - buttonId: Int, + button: NativeButton, overlayControlData: OverlayControlData, position: Pair ): InputOverlayDrawableButton { @@ -869,7 +868,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : res, defaultStateBitmap, pressedStateBitmap, - buttonId, + button, overlayControlData ) @@ -940,11 +939,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : res, defaultStateBitmap, pressedOneDirectionStateBitmap, - pressedTwoDirectionsStateBitmap, - ButtonType.DPAD_UP, - ButtonType.DPAD_DOWN, - ButtonType.DPAD_LEFT, - ButtonType.DPAD_RIGHT + pressedTwoDirectionsStateBitmap ) // Get the minimum and maximum coordinates of the screen where the button can be placed. @@ -993,8 +988,8 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : resOuter: Int, defaultResInner: Int, pressedResInner: Int, - joystick: Int, - buttonId: Int, + joystick: NativeAnalog, + button: NativeButton, overlayControlData: OverlayControlData, position: Pair ): InputOverlayDrawableJoystick { @@ -1042,7 +1037,7 @@ class InputOverlay(context: Context, attrs: AttributeSet?) : outerRect, innerRect, joystick, - buttonId, + button, overlayControlData.id ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt index b14a4f96e..fee3d04ee 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt @@ -9,7 +9,8 @@ import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent -import org.yuzu.yuzu_emu.NativeLibrary.ButtonState +import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState +import org.yuzu.yuzu_emu.features.input.model.NativeButton import org.yuzu.yuzu_emu.overlay.model.OverlayControlData /** @@ -19,13 +20,13 @@ import org.yuzu.yuzu_emu.overlay.model.OverlayControlData * @param res [Resources] instance. * @param defaultStateBitmap [Bitmap] to use with the default state Drawable. * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable. - * @param buttonId Identifier for this type of button. + * @param button [NativeButton] for this type of button. */ class InputOverlayDrawableButton( res: Resources, defaultStateBitmap: Bitmap, pressedStateBitmap: Bitmap, - val buttonId: Int, + val button: NativeButton, val overlayControlData: OverlayControlData ) { // The ID value what motion event is tracking diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt index 8aef6f5a5..0cb6ff244 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt @@ -9,7 +9,8 @@ import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent -import org.yuzu.yuzu_emu.NativeLibrary.ButtonState +import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState +import org.yuzu.yuzu_emu.features.input.model.NativeButton /** * Custom [BitmapDrawable] that is capable @@ -19,20 +20,12 @@ import org.yuzu.yuzu_emu.NativeLibrary.ButtonState * @param defaultStateBitmap [Bitmap] of the default state. * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction. * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction. - * @param buttonUp Identifier for the up button. - * @param buttonDown Identifier for the down button. - * @param buttonLeft Identifier for the left button. - * @param buttonRight Identifier for the right button. */ class InputOverlayDrawableDpad( res: Resources, defaultStateBitmap: Bitmap, pressedOneDirectionStateBitmap: Bitmap, - pressedTwoDirectionsStateBitmap: Bitmap, - buttonUp: Int, - buttonDown: Int, - buttonLeft: Int, - buttonRight: Int + pressedTwoDirectionsStateBitmap: Bitmap ) { /** * Gets one of the InputOverlayDrawableDpad's button IDs. @@ -40,10 +33,10 @@ class InputOverlayDrawableDpad( * @return the requested InputOverlayDrawableDpad's button ID. */ // The ID identifying what type of button this Drawable represents. - val upId: Int - val downId: Int - val leftId: Int - val rightId: Int + val up = NativeButton.DUp + val down = NativeButton.DDown + val left = NativeButton.DLeft + val right = NativeButton.DRight var trackId: Int val width: Int @@ -69,10 +62,6 @@ class InputOverlayDrawableDpad( this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap) width = this.defaultStateBitmap.intrinsicWidth height = this.defaultStateBitmap.intrinsicHeight - upId = buttonUp - downId = buttonDown - leftId = buttonLeft - rightId = buttonRight trackId = -1 } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt index 113bf7c24..4b07107fc 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt @@ -13,7 +13,9 @@ import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt -import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting /** @@ -26,8 +28,8 @@ import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick. * @param rectOuter [Rect] which represents the outer joystick bounds. * @param rectInner [Rect] which represents the inner joystick bounds. - * @param joystickId The ID value what type of joystick this Drawable represents. - * @param buttonId The ID value what type of button this Drawable represents. + * @param joystick The [NativeAnalog] this Drawable represents. + * @param button The [NativeButton] this Drawable represents. */ class InputOverlayDrawableJoystick( res: Resources, @@ -36,8 +38,8 @@ class InputOverlayDrawableJoystick( bitmapInnerPressed: Bitmap, rectOuter: Rect, rectInner: Rect, - val joystickId: Int, - val buttonId: Int, + val joystick: NativeAnalog, + val button: NativeButton, val prefId: String ) { // The ID value what motion event is tracking @@ -69,8 +71,7 @@ class InputOverlayDrawableJoystick( // TODO: Add button support val buttonStatus: Int - get() = - NativeLibrary.ButtonState.RELEASED + get() = ButtonState.RELEASED var bounds: Rect get() = outerBitmap.bounds set(bounds) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt index e63382e1d..2c7356e6a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt @@ -6,439 +6,89 @@ package org.yuzu.yuzu_emu.utils import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent -import kotlin.math.sqrt -import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.YuzuInputOverlayDevice +import org.yuzu.yuzu_emu.features.input.YuzuPhysicalDevice object InputHandler { - private var controllerIds = getGameControllerIds() - - fun initialize() { - // Connect first controller - NativeLibrary.onGamePadConnectEvent(getPlayerNumber(NativeLibrary.Player1Device)) - } - - fun updateControllerIds() { - controllerIds = getGameControllerIds() - } + var androidControllers = mapOf() + var registeredControllers = mutableListOf() fun dispatchKeyEvent(event: KeyEvent): Boolean { - val button: Int = when (event.device.vendorId) { - 0x045E -> getInputXboxButtonKey(event.keyCode) - 0x054C -> getInputDS5ButtonKey(event.keyCode) - 0x057E -> getInputJoyconButtonKey(event.keyCode) - 0x1532 -> getInputRazerButtonKey(event.keyCode) - 0x3537 -> getInputRedmagicButtonKey(event.keyCode) - 0x358A -> getInputBackboneLabsButtonKey(event.keyCode) - else -> getInputGenericButtonKey(event.keyCode) - } - val action = when (event.action) { - KeyEvent.ACTION_DOWN -> NativeLibrary.ButtonState.PRESSED - KeyEvent.ACTION_UP -> NativeLibrary.ButtonState.RELEASED + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED else -> return false } - // Ignore invalid buttons - if (button < 0) { - return false + var controllerData = androidControllers[event.device.controllerNumber] + if (controllerData == null) { + updateControllerData() + controllerData = androidControllers[event.device.controllerNumber] ?: return false } - return NativeLibrary.onGamePadButtonEvent( - getPlayerNumber(event.device.controllerNumber, event.deviceId), - button, + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, action ) + return true } fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { - val device = event.device - // Check every axis input available on the controller - for (range in device.motionRanges) { - val axis = range.axis - when (device.vendorId) { - 0x045E -> setGenericAxisInput(event, axis) - 0x054C -> setGenericAxisInput(event, axis) - 0x057E -> setJoyconAxisInput(event, axis) - 0x1532 -> setRazerAxisInput(event, axis) - else -> setGenericAxisInput(event, axis) - } + val controllerData = + androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) } - return true } - private fun getPlayerNumber(index: Int, deviceId: Int = -1): Int { - var deviceIndex = index - if (deviceId != -1) { - deviceIndex = controllerIds[deviceId] ?: 0 - } - - // TODO: Joycons are handled as different controllers. Find a way to merge them. - return when (deviceIndex) { - 2 -> NativeLibrary.Player2Device - 3 -> NativeLibrary.Player3Device - 4 -> NativeLibrary.Player4Device - 5 -> NativeLibrary.Player5Device - 6 -> NativeLibrary.Player6Device - 7 -> NativeLibrary.Player7Device - 8 -> NativeLibrary.Player8Device - else -> if (NativeLibrary.isHandheldOnly()) { - NativeLibrary.ConsoleDevice - } else { - NativeLibrary.Player1Device - } - } - } - - private fun setStickState(playerNumber: Int, index: Int, xAxis: Float, yAxis: Float) { - // Calculate vector size - val r2 = xAxis * xAxis + yAxis * yAxis - var r = sqrt(r2.toDouble()).toFloat() - - // Adjust range of joystick - val deadzone = 0.15f - var x = xAxis - var y = yAxis - - if (r > deadzone) { - val deadzoneFactor = 1.0f / r * (r - deadzone) / (1.0f - deadzone) - x *= deadzoneFactor - y *= deadzoneFactor - r *= deadzoneFactor - } else { - x = 0.0f - y = 0.0f - } - - // Normalize joystick - if (r > 1.0f) { - x /= r - y /= r - } - - NativeLibrary.onGamePadJoystickEvent( - playerNumber, - index, - x, - -y - ) - } - - private fun getAxisToButton(axis: Float): Int { - return if (axis > 0.5f) { - NativeLibrary.ButtonState.PRESSED - } else { - NativeLibrary.ButtonState.RELEASED - } - } - - private fun setAxisDpadState(playerNumber: Int, xAxis: Float, yAxis: Float) { - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.DPAD_UP, - getAxisToButton(-yAxis) - ) - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.DPAD_DOWN, - getAxisToButton(yAxis) - ) - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.DPAD_LEFT, - getAxisToButton(-xAxis) - ) - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.DPAD_RIGHT, - getAxisToButton(xAxis) - ) - } - - private fun getInputDS5ButtonKey(key: Int): Int { - // The missing ds5 buttons are axis - return when (key) { - KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B - KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A - KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y - KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X - KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L - KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R - KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L - KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R - KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS - KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS - else -> -1 - } - } - - private fun getInputJoyconButtonKey(key: Int): Int { - // Joycon support is half dead. A lot of buttons can't be mapped - return when (key) { - KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B - KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A - KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X - KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y - KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP - KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN - KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT - KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT - KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L - KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R - KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL - KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR - KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L - KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R - KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS - KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS - else -> -1 - } - } - - private fun getInputXboxButtonKey(key: Int): Int { - // The missing xbox buttons are axis - return when (key) { - KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A - KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B - KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X - KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y - KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L - KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R - KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L - KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R - KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS - KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS - else -> -1 - } - } - - private fun getInputRazerButtonKey(key: Int): Int { - // The missing xbox buttons are axis - return when (key) { - KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B - KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A - KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y - KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X - KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L - KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R - KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L - KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R - KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS - KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS - else -> -1 - } - } - - private fun getInputRedmagicButtonKey(key: Int): Int { - return when (key) { - KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B - KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A - KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y - KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X - KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L - KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R - KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL - KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR - KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L - KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R - KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS - KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS - else -> -1 - } - } - - private fun getInputBackboneLabsButtonKey(key: Int): Int { - return when (key) { - KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B - KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A - KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y - KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X - KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L - KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R - KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL - KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR - KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L - KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R - KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS - KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS - else -> -1 - } - } - - private fun getInputGenericButtonKey(key: Int): Int { - return when (key) { - KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A - KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B - KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X - KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y - KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP - KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN - KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT - KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT - KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L - KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R - KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL - KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR - KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L - KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R - KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS - KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS - else -> -1 - } - } - - private fun setGenericAxisInput(event: MotionEvent, axis: Int) { - val playerNumber = getPlayerNumber(event.device.controllerNumber, event.deviceId) - - when (axis) { - MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_L, - event.getAxisValue(MotionEvent.AXIS_X), - event.getAxisValue(MotionEvent.AXIS_Y) - ) - MotionEvent.AXIS_RX, MotionEvent.AXIS_RY -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_R, - event.getAxisValue(MotionEvent.AXIS_RX), - event.getAxisValue(MotionEvent.AXIS_RY) - ) - MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_R, - event.getAxisValue(MotionEvent.AXIS_Z), - event.getAxisValue(MotionEvent.AXIS_RZ) - ) - MotionEvent.AXIS_LTRIGGER -> - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.TRIGGER_ZL, - getAxisToButton(event.getAxisValue(MotionEvent.AXIS_LTRIGGER)) - ) - MotionEvent.AXIS_BRAKE -> - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.TRIGGER_ZL, - getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE)) - ) - MotionEvent.AXIS_RTRIGGER -> - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.TRIGGER_ZR, - getAxisToButton(event.getAxisValue(MotionEvent.AXIS_RTRIGGER)) - ) - MotionEvent.AXIS_GAS -> - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.TRIGGER_ZR, - getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS)) - ) - MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> - setAxisDpadState( - playerNumber, - event.getAxisValue(MotionEvent.AXIS_HAT_X), - event.getAxisValue(MotionEvent.AXIS_HAT_Y) - ) - } - } - - private fun setJoyconAxisInput(event: MotionEvent, axis: Int) { - // Joycon support is half dead. Right joystick doesn't work - val playerNumber = getPlayerNumber(event.device.controllerNumber, event.deviceId) - - when (axis) { - MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_L, - event.getAxisValue(MotionEvent.AXIS_X), - event.getAxisValue(MotionEvent.AXIS_Y) - ) - MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_R, - event.getAxisValue(MotionEvent.AXIS_Z), - event.getAxisValue(MotionEvent.AXIS_RZ) - ) - MotionEvent.AXIS_RX, MotionEvent.AXIS_RY -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_R, - event.getAxisValue(MotionEvent.AXIS_RX), - event.getAxisValue(MotionEvent.AXIS_RY) - ) - } - } - - private fun setRazerAxisInput(event: MotionEvent, axis: Int) { - val playerNumber = getPlayerNumber(event.device.controllerNumber, event.deviceId) - - when (axis) { - MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_L, - event.getAxisValue(MotionEvent.AXIS_X), - event.getAxisValue(MotionEvent.AXIS_Y) - ) - MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> - setStickState( - playerNumber, - NativeLibrary.StickType.STICK_R, - event.getAxisValue(MotionEvent.AXIS_Z), - event.getAxisValue(MotionEvent.AXIS_RZ) - ) - MotionEvent.AXIS_BRAKE -> - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.TRIGGER_ZL, - getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE)) - ) - MotionEvent.AXIS_GAS -> - NativeLibrary.onGamePadButtonEvent( - playerNumber, - NativeLibrary.ButtonType.TRIGGER_ZR, - getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS)) - ) - MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> - setAxisDpadState( - playerNumber, - event.getAxisValue(MotionEvent.AXIS_HAT_X), - event.getAxisValue(MotionEvent.AXIS_HAT_Y) - ) - } - } - - fun getGameControllerIds(): Map { - val gameControllerDeviceIds = mutableMapOf() + fun getDevices(): Map { + val gameControllerDeviceIds = mutableMapOf() val deviceIds = InputDevice.getDeviceIds() - var controllerSlot = 1 + var port = 0 + val inputSettings = NativeConfig.getInputSettings(true) deviceIds.forEach { deviceId -> InputDevice.getDevice(deviceId)?.apply { - // Don't over-assign controllers - if (controllerSlot >= 8) { - return gameControllerDeviceIds - } - // Verify that the device has gamepad buttons, control sticks, or both. if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK ) { - // This device is a game controller. Store its device ID. - if (deviceId and id and vendorId and productId != 0) { - // Additionally filter out devices that have no ID - gameControllerDeviceIds - .takeIf { !it.contains(deviceId) } - ?.put(deviceId, controllerSlot) - controllerSlot++ + if (!gameControllerDeviceIds.contains(controllerNumber)) { + gameControllerDeviceIds[controllerNumber] = YuzuPhysicalDevice( + this, + port, + inputSettings[port].useSystemVibrator + ) } + port++ } } } return gameControllerDeviceIds } + + fun updateControllerData() { + androidControllers = getDevices() + androidControllers.forEach { + NativeInput.registerController(it.value) + } + + // Register the input overlay on a dedicated port for all player 1 vibrations + NativeInput.registerController(YuzuInputOverlayDevice(androidControllers.isEmpty(), 100)) + registeredControllers.clear() + NativeInput.getInputDevices().forEach { + registeredControllers.add(ParamPackage(it)) + } + registeredControllers.sortBy { it.get("port", 0) } + } + + fun InputDevice.getGUID(): String = String.format("%016x%016x", productId, vendorId) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt index a4c14b3a7..7228f25d2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt @@ -6,6 +6,8 @@ package org.yuzu.yuzu_emu.utils import org.yuzu.yuzu_emu.model.GameDir import org.yuzu.yuzu_emu.overlay.model.OverlayControlData +import org.yuzu.yuzu_emu.features.input.model.PlayerInput + object NativeConfig { /** * Loads global config. @@ -168,4 +170,17 @@ object NativeConfig { */ @Synchronized external fun setOverlayControlData(overlayControlData: Array) + + @Synchronized + external fun getInputSettings(global: Boolean): Array + + @Synchronized + external fun setInputSettings(value: Array, global: Boolean) + + /** + * Saves control values for a specific player + * Must be used when per game config is loaded + */ + @Synchronized + external fun saveControlPlayerValues() } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt index 68ed66565..331b7ddca 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt @@ -14,7 +14,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import java.io.IOException -import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.features.input.NativeInput class NfcReader(private val activity: Activity) { private var nfcAdapter: NfcAdapter? = null @@ -76,12 +76,12 @@ class NfcReader(private val activity: Activity) { amiibo.connect() val tagData = ntag215ReadAll(amiibo) ?: return - NativeLibrary.onReadNfcTag(tagData) + NativeInput.onReadNfcTag(tagData) nfcAdapter?.ignore( tag, 1000, - { NativeLibrary.onRemoveNfcTag() }, + { NativeInput.onRemoveNfcTag() }, Handler(Looper.getMainLooper()) ) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt new file mode 100644 index 000000000..83fc7da3c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +// Kotlin version of src/common/param_package.h +class ParamPackage(serialized: String = "") { + private val KEY_VALUE_SEPARATOR = ":" + private val PARAM_SEPARATOR = "," + + private val ESCAPE_CHARACTER = "$" + private val KEY_VALUE_SEPARATOR_ESCAPE = "$0" + private val PARAM_SEPARATOR_ESCAPE = "$1" + private val ESCAPE_CHARACTER_ESCAPE = "$2" + + private val EMPTY_PLACEHOLDER = "[empty]" + + val data = mutableMapOf() + + init { + val pairs = serialized.split(PARAM_SEPARATOR) + for (pair in pairs) { + val keyValue = pair.split(KEY_VALUE_SEPARATOR).toMutableList() + if (keyValue.size != 2) { + Log.error("[ParamPackage] Invalid key pair $keyValue") + continue + } + + keyValue.forEachIndexed { i: Int, _: String -> + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR_ESCAPE, KEY_VALUE_SEPARATOR) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR_ESCAPE, PARAM_SEPARATOR) + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER_ESCAPE, ESCAPE_CHARACTER) + } + + set(keyValue[0], keyValue[1]) + } + } + + constructor(params: List>) : this() { + params.forEach { + data[it.first] = it.second + } + } + + fun serialize(): String { + if (data.isEmpty()) { + return EMPTY_PLACEHOLDER + } + + val result = StringBuilder() + data.forEach { + val keyValue = mutableListOf(it.key, it.value) + keyValue.forEachIndexed { i, _ -> + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER, ESCAPE_CHARACTER_ESCAPE) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR, PARAM_SEPARATOR_ESCAPE) + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR, KEY_VALUE_SEPARATOR_ESCAPE) + } + result.append("${keyValue[0]}$KEY_VALUE_SEPARATOR${keyValue[1]}$PARAM_SEPARATOR") + } + return result.removeSuffix(PARAM_SEPARATOR).toString() + } + + fun get(key: String, defaultValue: String): String = + if (has(key)) { + data[key]!! + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Int): Int = + if (has(key)) { + try { + data[key]!!.toInt() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to int") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + private fun Int.toBoolean(): Boolean = + if (this == 1) { + true + } else if (this == 0) { + false + } else { + throw Exception("Tried to convert a value to a boolean that was not 0 or 1!") + } + + fun get(key: String, defaultValue: Boolean): Boolean = + if (has(key)) { + try { + get(key, if (defaultValue) 1 else 0).toBoolean() + } catch (e: Exception) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to boolean") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Float): Float = + if (has(key)) { + try { + data[key]!!.toFloat() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to float") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun set(key: String, value: String) { + data[key] = value + } + + fun set(key: String, value: Int) { + data[key] = value.toString() + } + + fun Boolean.toInt(): Int = if (this) 1 else 0 + fun set(key: String, value: Boolean) { + data[key] = value.toInt().toString() + } + + fun set(key: String, value: Float) { + data[key] = value.toString() + } + + fun has(key: String): Boolean = data.containsKey(key) + + fun erase(key: String) = data.remove(key) + + fun clear() = data.clear() +} diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt index 20b319c12..ec8ae5c57 100644 --- a/src/android/app/src/main/jni/CMakeLists.txt +++ b/src/android/app/src/main/jni/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(yuzu-android SHARED native_log.cpp android_config.cpp android_config.h + native_input.cpp ) set_property(TARGET yuzu-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR}) diff --git a/src/android/app/src/main/jni/android_config.cpp b/src/android/app/src/main/jni/android_config.cpp index e147560c3..a79a64afb 100644 --- a/src/android/app/src/main/jni/android_config.cpp +++ b/src/android/app/src/main/jni/android_config.cpp @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include #include "android_config.h" #include "android_settings.h" #include "common/settings_setting.h" @@ -32,6 +34,7 @@ void AndroidConfig::ReadAndroidValues() { ReadOverlayValues(); } ReadDriverValues(); + ReadAndroidControlValues(); } void AndroidConfig::ReadAndroidUIValues() { @@ -107,6 +110,76 @@ void AndroidConfig::ReadOverlayValues() { EndGroup(); } +void AndroidConfig::ReadAndroidPlayerValues(std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix.append("player_").append(ToString(player_index)).append("_"); + } + + auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig()) { + const auto profile_name = + ReadStringSetting(std::string(player_prefix).append("profile_name")); + if (profile_name.empty()) { + // Use the global input config + player = Settings::values.players.GetValue(true)[player_index]; + player.profile_name = ""; + return; + } + } + + // Android doesn't have default options for controllers. We have the input overlay for that. + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param; + auto& player_buttons = player.buttons[i]; + + player_buttons = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeButton::mapping[i]), default_param); + if (player_buttons.empty()) { + player_buttons = default_param; + } + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param; + auto& player_analogs = player.analogs[i]; + + player_analogs = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), default_param); + if (player_analogs.empty()) { + player_analogs = default_param; + } + } + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param; + auto& player_motions = player.motions[i]; + + player_motions = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), default_param); + if (player_motions.empty()) { + player_motions = default_param; + } + } + player.use_system_vibrator = ReadBooleanSetting( + std::string(player_prefix).append("use_system_vibrator"), player_index == 0); +} + +void AndroidConfig::ReadAndroidControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + ReadAndroidPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + // ReadDebugControlValues(); + // ReadHidbusValues(); + + EndGroup(); +} + void AndroidConfig::SaveAndroidValues() { if (global) { SaveAndroidUIValues(); @@ -114,6 +187,7 @@ void AndroidConfig::SaveAndroidValues() { SaveOverlayValues(); } SaveDriverValues(); + SaveAndroidControlValues(); WriteToIni(); } @@ -187,6 +261,52 @@ void AndroidConfig::SaveOverlayValues() { EndGroup(); } +void AndroidConfig::SaveAndroidPlayerValues(std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix = std::string("player_").append(ToString(player_index)).append("_"); + } + + const auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig() && player.profile_name.empty()) { + // No custom profile selected + return; + } + + const std::string default_param; + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + WriteStringSetting(std::string(player_prefix).append(Settings::NativeButton::mapping[i]), + player.buttons[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + WriteStringSetting(std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), + player.analogs[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + WriteStringSetting(std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), + player.motions[i], std::make_optional(default_param)); + } + WriteBooleanSetting(std::string(player_prefix).append("use_system_vibrator"), + player.use_system_vibrator, std::make_optional(player_index == 0)); +} + +void AndroidConfig::SaveAndroidControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + SaveAndroidPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + // SaveDebugControlValues(); + // SaveHidbusValues(); + + EndGroup(); +} + std::vector& AndroidConfig::FindRelevantList(Settings::Category category) { auto& map = Settings::values.linkage.by_category; if (map.contains(category)) { @@ -194,3 +314,24 @@ std::vector& AndroidConfig::FindRelevantList(Settings:: } return AndroidSettings::values.linkage.by_category[category]; } + +void AndroidConfig::ReadAndroidControlPlayerValues(std::size_t player_index) { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + ReadPlayerValues(player_index); + ReadAndroidPlayerValues(player_index); + + EndGroup(); +} + +void AndroidConfig::SaveAndroidControlPlayerValues(std::size_t player_index) { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + LOG_DEBUG(Config, "Saving players control configuration values"); + SavePlayerValues(player_index); + SaveAndroidPlayerValues(player_index); + + EndGroup(); + + WriteToIni(); +} diff --git a/src/android/app/src/main/jni/android_config.h b/src/android/app/src/main/jni/android_config.h index 693e1e3f0..28ef5d0a8 100644 --- a/src/android/app/src/main/jni/android_config.h +++ b/src/android/app/src/main/jni/android_config.h @@ -13,7 +13,12 @@ public: void ReloadAllValues() override; void SaveAllValues() override; + void ReadAndroidControlPlayerValues(std::size_t player_index); + void SaveAndroidControlPlayerValues(std::size_t player_index); + protected: + void ReadAndroidPlayerValues(std::size_t player_index); + void ReadAndroidControlValues(); void ReadAndroidValues(); void ReadAndroidUIValues(); void ReadDriverValues(); @@ -27,6 +32,8 @@ protected: void ReadUILayoutValues() override {} void ReadMultiplayerValues() override {} + void SaveAndroidPlayerValues(std::size_t player_index); + void SaveAndroidControlValues(); void SaveAndroidValues(); void SaveAndroidUIValues(); void SaveDriverValues(); diff --git a/src/android/app/src/main/jni/emu_window/emu_window.cpp b/src/android/app/src/main/jni/emu_window/emu_window.cpp index c927cddda..2768a01c9 100644 --- a/src/android/app/src/main/jni/emu_window/emu_window.cpp +++ b/src/android/app/src/main/jni/emu_window/emu_window.cpp @@ -5,6 +5,7 @@ #include "common/android/id_cache.h" #include "common/logging/log.h" +#include "input_common/drivers/android.h" #include "input_common/drivers/touch_screen.h" #include "input_common/drivers/virtual_amiibo.h" #include "input_common/drivers/virtual_gamepad.h" @@ -22,43 +23,6 @@ void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) { window_info.render_surface = reinterpret_cast(surface); } -void EmuWindow_Android::OnTouchPressed(int id, float x, float y) { - const auto [touch_x, touch_y] = MapToTouchScreen(x, y); - m_input_subsystem->GetTouchScreen()->TouchPressed(touch_x, touch_y, id); -} - -void EmuWindow_Android::OnTouchMoved(int id, float x, float y) { - const auto [touch_x, touch_y] = MapToTouchScreen(x, y); - m_input_subsystem->GetTouchScreen()->TouchMoved(touch_x, touch_y, id); -} - -void EmuWindow_Android::OnTouchReleased(int id) { - m_input_subsystem->GetTouchScreen()->TouchReleased(id); -} - -void EmuWindow_Android::OnGamepadButtonEvent(int player_index, int button_id, bool pressed) { - m_input_subsystem->GetVirtualGamepad()->SetButtonState(player_index, button_id, pressed); -} - -void EmuWindow_Android::OnGamepadJoystickEvent(int player_index, int stick_id, float x, float y) { - m_input_subsystem->GetVirtualGamepad()->SetStickPosition(player_index, stick_id, x, y); -} - -void EmuWindow_Android::OnGamepadMotionEvent(int player_index, u64 delta_timestamp, float gyro_x, - float gyro_y, float gyro_z, float accel_x, - float accel_y, float accel_z) { - m_input_subsystem->GetVirtualGamepad()->SetMotionState( - player_index, delta_timestamp, gyro_x, gyro_y, gyro_z, accel_x, accel_y, accel_z); -} - -void EmuWindow_Android::OnReadNfcTag(std::span data) { - m_input_subsystem->GetVirtualAmiibo()->LoadAmiibo(data); -} - -void EmuWindow_Android::OnRemoveNfcTag() { - m_input_subsystem->GetVirtualAmiibo()->CloseAmiibo(); -} - void EmuWindow_Android::OnFrameDisplayed() { if (!m_first_frame) { Common::Android::RunJNIOnFiber( @@ -67,10 +31,9 @@ void EmuWindow_Android::OnFrameDisplayed() { } } -EmuWindow_Android::EmuWindow_Android(InputCommon::InputSubsystem* input_subsystem, - ANativeWindow* surface, +EmuWindow_Android::EmuWindow_Android(ANativeWindow* surface, std::shared_ptr driver_library) - : m_input_subsystem{input_subsystem}, m_driver_library{driver_library} { + : m_driver_library{driver_library} { LOG_INFO(Frontend, "initializing"); if (!surface) { @@ -80,10 +43,4 @@ EmuWindow_Android::EmuWindow_Android(InputCommon::InputSubsystem* input_subsyste OnSurfaceChanged(surface); window_info.type = Core::Frontend::WindowSystemType::Android; - - m_input_subsystem->Initialize(); -} - -EmuWindow_Android::~EmuWindow_Android() { - m_input_subsystem->Shutdown(); } diff --git a/src/android/app/src/main/jni/emu_window/emu_window.h b/src/android/app/src/main/jni/emu_window/emu_window.h index a34a0e479..34704ae95 100644 --- a/src/android/app/src/main/jni/emu_window/emu_window.h +++ b/src/android/app/src/main/jni/emu_window/emu_window.h @@ -30,21 +30,12 @@ private: class EmuWindow_Android final : public Core::Frontend::EmuWindow { public: - EmuWindow_Android(InputCommon::InputSubsystem* input_subsystem, ANativeWindow* surface, + EmuWindow_Android(ANativeWindow* surface, std::shared_ptr driver_library); - ~EmuWindow_Android(); + ~EmuWindow_Android() = default; void OnSurfaceChanged(ANativeWindow* surface); - void OnTouchPressed(int id, float x, float y); - void OnTouchMoved(int id, float x, float y); - void OnTouchReleased(int id); - void OnGamepadButtonEvent(int player_index, int button_id, bool pressed); - void OnGamepadJoystickEvent(int player_index, int stick_id, float x, float y); - void OnGamepadMotionEvent(int player_index, u64 delta_timestamp, float gyro_x, float gyro_y, - float gyro_z, float accel_x, float accel_y, float accel_z); - void OnReadNfcTag(std::span data); - void OnRemoveNfcTag(); void OnFrameDisplayed() override; std::unique_ptr CreateSharedContext() const override { @@ -55,8 +46,6 @@ public: }; private: - InputCommon::InputSubsystem* m_input_subsystem{}; - float m_window_width{}; float m_window_height{}; diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index a4d8454e8..50cef5d2a 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -88,6 +88,10 @@ FileSys::ManualContentProvider* EmulationSession::GetContentProvider() { return m_manual_provider.get(); } +InputCommon::InputSubsystem& EmulationSession::GetInputSubsystem() { + return m_input_subsystem; +} + const EmuWindow_Android& EmulationSession::Window() const { return *m_window; } @@ -198,6 +202,8 @@ void EmulationSession::InitializeSystem(bool reload) { Common::Log::Initialize(); Common::Log::SetColorConsoleBackendEnabled(true); Common::Log::Start(); + + m_input_subsystem.Initialize(); } // Initialize filesystem. @@ -222,8 +228,7 @@ Core::SystemResultStatus EmulationSession::InitializeEmulation(const std::string std::scoped_lock lock(m_mutex); // Create the render window. - m_window = - std::make_unique(&m_input_subsystem, m_native_window, m_vulkan_library); + m_window = std::make_unique(m_native_window, m_vulkan_library); // Initialize system. jauto android_keyboard = std::make_unique(); @@ -355,60 +360,6 @@ void EmulationSession::RunEmulation() { m_applet_id = static_cast(Service::AM::AppletId::Application); } -bool EmulationSession::IsHandheldOnly() { - jconst npad_style_set = m_system.HIDCore().GetSupportedStyleTag(); - - if (npad_style_set.fullkey == 1) { - return false; - } - - if (npad_style_set.handheld == 0) { - return false; - } - - return !Settings::IsDockedMode(); -} - -void EmulationSession::SetDeviceType([[maybe_unused]] int index, int type) { - jauto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index); - controller->SetNpadStyleIndex(static_cast(type)); -} - -void EmulationSession::OnGamepadConnectEvent([[maybe_unused]] int index) { - jauto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index); - - // Ensure that player1 is configured correctly and handheld disconnected - if (controller->GetNpadIdType() == Core::HID::NpadIdType::Player1) { - jauto handheld = m_system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); - - if (controller->GetNpadStyleIndex() == Core::HID::NpadStyleIndex::Handheld) { - handheld->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Fullkey); - controller->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Fullkey); - handheld->Disconnect(); - } - } - - // Ensure that handheld is configured correctly and player 1 disconnected - if (controller->GetNpadIdType() == Core::HID::NpadIdType::Handheld) { - jauto player1 = m_system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); - - if (controller->GetNpadStyleIndex() != Core::HID::NpadStyleIndex::Handheld) { - player1->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Handheld); - controller->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Handheld); - player1->Disconnect(); - } - } - - if (!controller->IsConnected()) { - controller->Connect(); - } -} - -void EmulationSession::OnGamepadDisconnectEvent([[maybe_unused]] int index) { - jauto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index); - controller->Disconnect(); -} - Common::Android::SoftwareKeyboard::AndroidKeyboard* EmulationSession::SoftwareKeyboard() { return m_software_keyboard; } @@ -574,14 +525,14 @@ jobjectArray Java_org_yuzu_yuzu_1emu_utils_GpuDriverHelper_getSystemDriverInfo( nullptr, nullptr, file_redirect_dir_, nullptr); auto driver_library = std::make_shared(handle); InputCommon::InputSubsystem input_subsystem; - auto m_window = std::make_unique( - &input_subsystem, ANativeWindow_fromSurface(env, j_surf), driver_library); + auto window = + std::make_unique(ANativeWindow_fromSurface(env, j_surf), driver_library); Vulkan::vk::InstanceDispatch dld; Vulkan::vk::Instance vk_instance = Vulkan::CreateInstance( *driver_library, dld, VK_API_VERSION_1_1, Core::Frontend::WindowSystemType::Android); - auto surface = Vulkan::CreateSurface(vk_instance, m_window->GetWindowInfo()); + auto surface = Vulkan::CreateSurface(vk_instance, window->GetWindowInfo()); auto device = Vulkan::CreateDevice(vk_instance, dld, *surface); @@ -622,103 +573,6 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isPaused(JNIEnv* env, jclass claz return static_cast(EmulationSession::GetInstance().IsPaused()); } -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isHandheldOnly(JNIEnv* env, jclass clazz) { - return EmulationSession::GetInstance().IsHandheldOnly(); -} - -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_setDeviceType(JNIEnv* env, jclass clazz, - jint j_device, jint j_type) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().SetDeviceType(j_device, j_type); - } - return static_cast(true); -} - -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadConnectEvent(JNIEnv* env, jclass clazz, - jint j_device) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().OnGamepadConnectEvent(j_device); - } - return static_cast(true); -} - -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadDisconnectEvent(JNIEnv* env, jclass clazz, - jint j_device) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().OnGamepadDisconnectEvent(j_device); - } - return static_cast(true); -} -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadButtonEvent(JNIEnv* env, jclass clazz, - jint j_device, jint j_button, - jint action) { - if (EmulationSession::GetInstance().IsRunning()) { - // Ensure gamepad is connected - EmulationSession::GetInstance().OnGamepadConnectEvent(j_device); - EmulationSession::GetInstance().Window().OnGamepadButtonEvent(j_device, j_button, - action != 0); - } - return static_cast(true); -} - -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadJoystickEvent(JNIEnv* env, jclass clazz, - jint j_device, jint stick_id, - jfloat x, jfloat y) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().Window().OnGamepadJoystickEvent(j_device, stick_id, x, y); - } - return static_cast(true); -} - -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadMotionEvent( - JNIEnv* env, jclass clazz, jint j_device, jlong delta_timestamp, jfloat gyro_x, jfloat gyro_y, - jfloat gyro_z, jfloat accel_x, jfloat accel_y, jfloat accel_z) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().Window().OnGamepadMotionEvent( - j_device, delta_timestamp, gyro_x, gyro_y, gyro_z, accel_x, accel_y, accel_z); - } - return static_cast(true); -} - -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onReadNfcTag(JNIEnv* env, jclass clazz, - jbyteArray j_data) { - jboolean isCopy{false}; - std::span data(reinterpret_cast(env->GetByteArrayElements(j_data, &isCopy)), - static_cast(env->GetArrayLength(j_data))); - - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().Window().OnReadNfcTag(data); - } - return static_cast(true); -} - -jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onRemoveNfcTag(JNIEnv* env, jclass clazz) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().Window().OnRemoveNfcTag(); - } - return static_cast(true); -} - -void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchPressed(JNIEnv* env, jclass clazz, jint id, - jfloat x, jfloat y) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().Window().OnTouchPressed(id, x, y); - } -} - -void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, jclass clazz, jint id, - jfloat x, jfloat y) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().Window().OnTouchMoved(id, x, y); - } -} - -void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchReleased(JNIEnv* env, jclass clazz, jint id) { - if (EmulationSession::GetInstance().IsRunning()) { - EmulationSession::GetInstance().Window().OnTouchReleased(id); - } -} - void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass clazz, jboolean reload) { // Initialize the emulated system. @@ -759,6 +613,7 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getGpuDriver(JNIEnv* env, jobject void Java_org_yuzu_yuzu_1emu_NativeLibrary_applySettings(JNIEnv* env, jobject jobj) { EmulationSession::GetInstance().System().ApplySettings(); + EmulationSession::GetInstance().System().HIDCore().ReloadInputDevices(); } void Java_org_yuzu_yuzu_1emu_NativeLibrary_logSettings(JNIEnv* env, jobject jobj) { diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h index 47936e305..6a4551ada 100644 --- a/src/android/app/src/main/jni/native.h +++ b/src/android/app/src/main/jni/native.h @@ -23,6 +23,7 @@ public: const Core::System& System() const; Core::System& System(); FileSys::ManualContentProvider* GetContentProvider(); + InputCommon::InputSubsystem& GetInputSubsystem(); const EmuWindow_Android& Window() const; EmuWindow_Android& Window(); @@ -50,10 +51,6 @@ public: const std::size_t program_index, const bool frontend_initiated); - bool IsHandheldOnly(); - void SetDeviceType([[maybe_unused]] int index, int type); - void OnGamepadConnectEvent([[maybe_unused]] int index); - void OnGamepadDisconnectEvent([[maybe_unused]] int index); Common::Android::SoftwareKeyboard::AndroidKeyboard* SoftwareKeyboard(); static void OnEmulationStarted(); diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp index 8ae10fbc7..0b26280c6 100644 --- a/src/android/app/src/main/jni/native_config.cpp +++ b/src/android/app/src/main/jni/native_config.cpp @@ -3,7 +3,6 @@ #include -#include #include #include "android_config.h" @@ -425,4 +424,120 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setOverlayControlData( } } +jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getInputSettings(JNIEnv* env, jobject obj, + jboolean j_global) { + Settings::values.players.SetGlobal(static_cast(j_global)); + auto& players = Settings::values.players.GetValue(); + jobjectArray j_input_settings = + env->NewObjectArray(players.size(), Common::Android::GetPlayerInputClass(), nullptr); + for (size_t i = 0; i < players.size(); ++i) { + auto j_connected = static_cast(players[i].connected); + + jobjectArray j_buttons = env->NewObjectArray( + players[i].buttons.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].buttons.size(); ++j) { + env->SetObjectArrayElement(j_buttons, j, + Common::Android::ToJString(env, players[i].buttons[j])); + } + jobjectArray j_analogs = env->NewObjectArray( + players[i].analogs.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].analogs.size(); ++j) { + env->SetObjectArrayElement(j_analogs, j, + Common::Android::ToJString(env, players[i].analogs[j])); + } + jobjectArray j_motions = env->NewObjectArray( + players[i].motions.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].motions.size(); ++j) { + env->SetObjectArrayElement(j_motions, j, + Common::Android::ToJString(env, players[i].motions[j])); + } + + auto j_vibration_enabled = static_cast(players[i].vibration_enabled); + auto j_vibration_strength = static_cast(players[i].vibration_strength); + + auto j_body_color_left = static_cast(players[i].body_color_left); + auto j_body_color_right = static_cast(players[i].body_color_right); + auto j_button_color_left = static_cast(players[i].button_color_left); + auto j_button_color_right = static_cast(players[i].button_color_right); + + auto j_profile_name = Common::Android::ToJString(env, players[i].profile_name); + + auto j_use_system_vibrator = players[i].use_system_vibrator; + + jobject playerInput = env->NewObject( + Common::Android::GetPlayerInputClass(), Common::Android::GetPlayerInputConstructor(), + j_connected, j_buttons, j_analogs, j_motions, j_vibration_enabled, j_vibration_strength, + j_body_color_left, j_body_color_right, j_button_color_left, j_button_color_right, + j_profile_name, j_use_system_vibrator); + env->SetObjectArrayElement(j_input_settings, i, playerInput); + } + return j_input_settings; +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setInputSettings(JNIEnv* env, jobject obj, + jobjectArray j_value, + jboolean j_global) { + auto& players = Settings::values.players.GetValue(static_cast(j_global)); + int playersSize = env->GetArrayLength(j_value); + for (int i = 0; i < playersSize; ++i) { + jobject jplayer = env->GetObjectArrayElement(j_value, i); + + players[i].connected = static_cast( + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputConnectedField())); + + auto j_buttons_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputButtonsField())); + int buttons_size = env->GetArrayLength(j_buttons_array); + for (int j = 0; j < buttons_size; ++j) { + auto button = static_cast(env->GetObjectArrayElement(j_buttons_array, j)); + players[i].buttons[j] = Common::Android::GetJString(env, button); + } + auto j_analogs_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputAnalogsField())); + int analogs_size = env->GetArrayLength(j_analogs_array); + for (int j = 0; j < analogs_size; ++j) { + auto analog = static_cast(env->GetObjectArrayElement(j_analogs_array, j)); + players[i].analogs[j] = Common::Android::GetJString(env, analog); + } + auto j_motions_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputMotionsField())); + int motions_size = env->GetArrayLength(j_motions_array); + for (int j = 0; j < motions_size; ++j) { + auto motion = static_cast(env->GetObjectArrayElement(j_motions_array, j)); + players[i].motions[j] = Common::Android::GetJString(env, motion); + } + + players[i].vibration_enabled = static_cast( + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputVibrationEnabledField())); + players[i].vibration_strength = static_cast( + env->GetIntField(jplayer, Common::Android::GetPlayerInputVibrationStrengthField())); + + players[i].body_color_left = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputBodyColorLeftField())); + players[i].body_color_right = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputBodyColorRightField())); + players[i].button_color_left = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputButtonColorLeftField())); + players[i].button_color_right = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputButtonColorRightField())); + + auto profileName = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputProfileNameField())); + players[i].profile_name = Common::Android::GetJString(env, profileName); + + players[i].use_system_vibrator = + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputUseSystemVibratorField()); + } +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_saveControlPlayerValues(JNIEnv* env, jobject obj) { + Settings::values.players.SetGlobal(false); + + // Clear all controls from the config in case the user reverted back to globals + per_game_config->ClearControlPlayerValues(); + for (size_t index = 0; index < Settings::values.players.GetValue().size(); ++index) { + per_game_config->SaveAndroidControlPlayerValues(index); + } +} + } // extern "C" diff --git a/src/android/app/src/main/jni/native_input.cpp b/src/android/app/src/main/jni/native_input.cpp new file mode 100644 index 000000000..ddf2f297b --- /dev/null +++ b/src/android/app/src/main/jni/native_input.cpp @@ -0,0 +1,631 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include + +#include "android_config.h" +#include "common/android/android_common.h" +#include "common/android/id_cache.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "input_common/drivers/android.h" +#include "input_common/drivers/touch_screen.h" +#include "input_common/drivers/virtual_amiibo.h" +#include "input_common/drivers/virtual_gamepad.h" +#include "native.h" + +std::unordered_map> map_profiles; + +bool IsHandheldOnly() { + const auto npad_style_set = + EmulationSession::GetInstance().System().HIDCore().GetSupportedStyleTag(); + + if (npad_style_set.fullkey == 1) { + return false; + } + + if (npad_style_set.handheld == 0) { + return false; + } + + return !Settings::IsDockedMode(); +} + +std::filesystem::path GetNameWithoutExtension(std::filesystem::path filename) { + return filename.replace_extension(); +} + +bool IsProfileNameValid(std::string_view profile_name) { + return profile_name.find_first_of("<>:;\"/\\|,.!?*") == std::string::npos; +} + +bool ProfileExistsInFilesystem(std::string_view profile_name) { + return Common::FS::Exists(Common::FS::GetYuzuPath(Common::FS::YuzuPath::ConfigDir) / "input" / + fmt::format("{}.ini", profile_name)); +} + +bool ProfileExistsInMap(const std::string& profile_name) { + return map_profiles.find(profile_name) != map_profiles.end(); +} + +bool SaveProfile(const std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + Settings::values.players.GetValue()[player_index].profile_name = profile_name; + map_profiles[profile_name]->SaveAndroidControlPlayerValues(player_index); + return true; +} + +bool LoadProfile(std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name)) { + map_profiles.erase(profile_name); + return false; + } + + LOG_INFO(Config, "Loading input profile `{}`", profile_name); + + Settings::values.players.GetValue()[player_index].profile_name = profile_name; + map_profiles[profile_name]->ReadAndroidControlPlayerValues(player_index); + return true; +} + +void ApplyControllerConfig(size_t player_index, + const std::function& apply) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + if (player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + handheld->EnableConfiguration(); + player_one->EnableConfiguration(); + apply(handheld); + apply(player_one); + handheld->DisableConfiguration(); + player_one->DisableConfiguration(); + handheld->SaveCurrentConfig(); + player_one->SaveCurrentConfig(); + } else { + auto* controller = hid_core.GetEmulatedControllerByIndex(player_index); + controller->EnableConfiguration(); + apply(controller); + controller->DisableConfiguration(); + controller->SaveCurrentConfig(); + } +} + +void ConnectController(size_t player_index, bool connected) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + if (player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + handheld->EnableConfiguration(); + player_one->EnableConfiguration(); + if (player_one->GetNpadStyleIndex(true) == Core::HID::NpadStyleIndex::Handheld) { + if (connected) { + handheld->Connect(); + } else { + handheld->Disconnect(); + } + player_one->Disconnect(); + } else { + if (connected) { + player_one->Connect(); + } else { + player_one->Disconnect(); + } + handheld->Disconnect(); + } + handheld->DisableConfiguration(); + player_one->DisableConfiguration(); + handheld->SaveCurrentConfig(); + player_one->SaveCurrentConfig(); + } else { + auto* controller = hid_core.GetEmulatedControllerByIndex(player_index); + controller->EnableConfiguration(); + if (connected) { + controller->Connect(); + } else { + controller->Disconnect(); + } + controller->DisableConfiguration(); + controller->SaveCurrentConfig(); + } +} + +extern "C" { + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_isHandheldOnly(JNIEnv* env, + jobject j_obj) { + return IsHandheldOnly(); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onGamePadButtonEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jint j_button_id, jint j_action) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetButtonState( + Common::Android::GetJString(env, j_guid), j_port, j_button_id, j_action != 0); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onGamePadAxisEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jint j_stick_id, jfloat j_value) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetAxisPosition( + Common::Android::GetJString(env, j_guid), j_port, j_stick_id, j_value); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onGamePadMotionEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jlong j_delta_timestamp, + jfloat j_x_gyro, jfloat j_y_gyro, jfloat j_z_gyro, jfloat j_x_accel, jfloat j_y_accel, + jfloat j_z_accel) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetMotionState( + Common::Android::GetJString(env, j_guid), j_port, j_delta_timestamp, j_x_gyro, j_y_gyro, + j_z_gyro, j_x_accel, j_y_accel, j_z_accel); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onReadNfcTag(JNIEnv* env, jobject j_obj, + jbyteArray j_data) { + jboolean isCopy{false}; + std::span data(reinterpret_cast(env->GetByteArrayElements(j_data, &isCopy)), + static_cast(env->GetArrayLength(j_data))); + + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo()->LoadAmiibo(data); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onRemoveNfcTag(JNIEnv* env, jobject j_obj) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo()->CloseAmiibo(); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onTouchPressed(JNIEnv* env, jobject j_obj, + jint j_id, jfloat j_x_axis, + jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchPressed( + j_id, j_x_axis, j_y_axis); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onTouchMoved(JNIEnv* env, jobject j_obj, + jint j_id, jfloat j_x_axis, + jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchMoved( + j_id, j_x_axis, j_y_axis); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onTouchReleased(JNIEnv* env, jobject j_obj, + jint j_id) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchReleased(j_id); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onOverlayButtonEventImpl( + JNIEnv* env, jobject j_obj, jint j_port, jint j_button_id, jint j_action) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetButtonState( + j_port, j_button_id, j_action == 1); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onOverlayJoystickEventImpl( + JNIEnv* env, jobject j_obj, jint j_port, jint j_stick_id, jfloat j_x_axis, jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetStickPosition( + j_port, j_stick_id, j_x_axis, j_y_axis); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_onDeviceMotionEvent( + JNIEnv* env, jobject j_obj, jint j_port, jlong j_delta_timestamp, jfloat j_x_gyro, + jfloat j_y_gyro, jfloat j_z_gyro, jfloat j_x_accel, jfloat j_y_accel, jfloat j_z_accel) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetMotionState( + j_port, j_delta_timestamp, j_x_gyro, j_y_gyro, j_z_gyro, j_x_accel, j_y_accel, + j_z_accel); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_reloadInputDevices(JNIEnv* env, + jobject j_obj) { + EmulationSession::GetInstance().System().HIDCore().ReloadInputDevices(); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_registerController(JNIEnv* env, + jobject j_obj, + jobject j_device) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->RegisterController(j_device); +} + +jobjectArray Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getInputDevices(JNIEnv* env, + jobject j_obj) { + auto devices = EmulationSession::GetInstance().GetInputSubsystem().GetInputDevices(); + jobjectArray jdevices = env->NewObjectArray(devices.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < devices.size(); ++i) { + env->SetObjectArrayElement(jdevices, i, + Common::Android::ToJString(env, devices[i].Serialize())); + } + return jdevices; +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_loadInputProfiles(JNIEnv* env, + jobject j_obj) { + map_profiles.clear(); + const auto input_profile_loc = + Common::FS::GetYuzuPath(Common::FS::YuzuPath::ConfigDir) / "input"; + + if (Common::FS::IsDir(input_profile_loc)) { + Common::FS::IterateDirEntries( + input_profile_loc, + [&](const std::filesystem::path& full_path) { + const auto filename = full_path.filename(); + const auto name_without_ext = + Common::FS::PathToUTF8String(GetNameWithoutExtension(filename)); + + if (filename.extension() == ".ini" && IsProfileNameValid(name_without_ext)) { + map_profiles.insert_or_assign( + name_without_ext, std::make_unique( + name_without_ext, Config::ConfigType::InputProfile)); + } + + return true; + }, + Common::FS::DirEntryFilter::File); + } +} + +jobjectArray Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getInputProfileNames( + JNIEnv* env, jobject j_obj) { + std::vector profile_names; + profile_names.reserve(map_profiles.size()); + + auto it = map_profiles.cbegin(); + while (it != map_profiles.cend()) { + const auto& [profile_name, config] = *it; + if (!ProfileExistsInFilesystem(profile_name)) { + it = map_profiles.erase(it); + continue; + } + + profile_names.push_back(profile_name); + ++it; + } + + std::stable_sort(profile_names.begin(), profile_names.end()); + + jobjectArray j_profile_names = + env->NewObjectArray(profile_names.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < profile_names.size(); ++i) { + env->SetObjectArrayElement(j_profile_names, i, + Common::Android::ToJString(env, profile_names[i])); + } + + return j_profile_names; +} + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_isProfileNameValid(JNIEnv* env, + jobject j_obj, + jstring j_name) { + return Common::Android::GetJString(env, j_name).find_first_of("<>:;\"/\\|,.!?*") == + std::string::npos; +} + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_createProfile(JNIEnv* env, + jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + if (ProfileExistsInMap(profile_name)) { + return false; + } + + map_profiles.insert_or_assign( + profile_name, + std::make_unique(profile_name, Config::ConfigType::InputProfile)); + + return SaveProfile(profile_name, j_player_index); +} + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_deleteProfile(JNIEnv* env, + jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name) || + Common::FS::RemoveFile(map_profiles[profile_name]->GetConfigFilePath())) { + map_profiles.erase(profile_name); + } + + Settings::values.players.GetValue()[j_player_index].profile_name = ""; + return !ProfileExistsInMap(profile_name) && !ProfileExistsInFilesystem(profile_name); +} + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_loadProfile(JNIEnv* env, jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + return LoadProfile(profile_name, j_player_index); +} + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_saveProfile(JNIEnv* env, jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + return SaveProfile(profile_name, j_player_index); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_loadPerGameConfiguration( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_selected_index, + jstring j_selected_profile_name) { + static constexpr size_t HANDHELD_INDEX = 8; + + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + Settings::values.players.SetGlobal(false); + + auto profile_name = Common::Android::GetJString(env, j_selected_profile_name); + auto* emulated_controller = hid_core.GetEmulatedControllerByIndex(j_player_index); + + if (j_selected_index == 0) { + Settings::values.players.GetValue()[j_player_index].profile_name = ""; + if (j_player_index == 0) { + Settings::values.players.GetValue()[HANDHELD_INDEX] = {}; + } + Settings::values.players.SetGlobal(true); + emulated_controller->ReloadFromSettings(); + return; + } + if (profile_name.empty()) { + return; + } + auto& player = Settings::values.players.GetValue()[j_player_index]; + auto& global_player = Settings::values.players.GetValue(true)[j_player_index]; + player.profile_name = profile_name; + global_player.profile_name = profile_name; + // Read from the profile into the custom player settings + LoadProfile(profile_name, j_player_index); + // Make sure the controller is connected + player.connected = true; + + emulated_controller->ReloadFromSettings(); + + if (j_player_index > 0) { + return; + } + // Handle Handheld cases + auto& handheld_player = Settings::values.players.GetValue()[HANDHELD_INDEX]; + auto* handheld_controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + if (player.controller_type == Settings::ControllerType::Handheld) { + handheld_player = player; + } else { + handheld_player = {}; + } + handheld_controller->ReloadFromSettings(); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_beginMapping(JNIEnv* env, jobject j_obj, + jint jtype) { + EmulationSession::GetInstance().GetInputSubsystem().BeginMapping( + static_cast(jtype)); +} + +jstring Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getNextInput(JNIEnv* env, + jobject j_obj) { + return Common::Android::ToJString( + env, EmulationSession::GetInstance().GetInputSubsystem().GetNextInput().Serialize()); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_stopMapping(JNIEnv* env, jobject j_obj) { + EmulationSession::GetInstance().GetInputSubsystem().StopMapping(); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_updateMappingsWithDefaultImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jstring j_device_params, + jstring j_display_name) { + auto& input_subsystem = EmulationSession::GetInstance().GetInputSubsystem(); + + // Clear all previous mappings + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(button_id, {}); + }); + } + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(analog_id, {}); + }); + } + + // Apply new mappings + auto device = Common::ParamPackage(Common::Android::GetJString(env, j_device_params)); + auto button_mappings = input_subsystem.GetButtonMappingForDevice(device); + auto analog_mappings = input_subsystem.GetAnalogMappingForDevice(device); + auto display_name = Common::Android::GetJString(env, j_display_name); + for (const auto& button_mapping : button_mappings) { + const std::size_t index = button_mapping.first; + auto named_mapping = button_mapping.second; + named_mapping.Set("display", display_name); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(index, named_mapping); + }); + } + for (const auto& analog_mapping : analog_mappings) { + const std::size_t index = analog_mapping.first; + auto named_mapping = analog_mapping.second; + named_mapping.Set("display", display_name); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(index, named_mapping); + }); + } +} + +jstring Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getButtonParamImpl(JNIEnv* env, + jobject j_obj, + jint j_player_index, + jint j_button) { + return Common::Android::ToJString(env, EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetButtonParam(j_button) + .Serialize()); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_setButtonParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_button_id, jstring j_param) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(j_button_id, + Common::ParamPackage(Common::Android::GetJString(env, j_param))); + }); +} + +jstring Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getStickParamImpl(JNIEnv* env, + jobject j_obj, + jint j_player_index, + jint j_stick) { + return Common::Android::ToJString(env, EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetStickParam(j_stick) + .Serialize()); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_setStickParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_stick_id, jstring j_param) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(j_stick_id, + Common::ParamPackage(Common::Android::GetJString(env, j_param))); + }); +} + +jint Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getButtonNameImpl(JNIEnv* env, + jobject j_obj, + jstring j_param) { + return static_cast(EmulationSession::GetInstance().GetInputSubsystem().GetButtonName( + Common::ParamPackage(Common::Android::GetJString(env, j_param)))); +} + +jintArray Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getSupportedStyleTagsImpl( + JNIEnv* env, jobject j_obj, jint j_player_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + const auto npad_style_set = hid_core.GetSupportedStyleTag(); + std::vector supported_indexes; + if (npad_style_set.fullkey == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::Fullkey)); + } + + if (npad_style_set.joycon_dual == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconDual)); + } + + if (npad_style_set.joycon_left == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconLeft)); + } + + if (npad_style_set.joycon_right == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconRight)); + } + + if (j_player_index == 0 && npad_style_set.handheld == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::Handheld)); + } + + if (npad_style_set.gamecube == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::GameCube)); + } + + jintArray j_supported_indexes = env->NewIntArray(supported_indexes.size()); + env->SetIntArrayRegion(j_supported_indexes, 0, supported_indexes.size(), + supported_indexes.data()); + return j_supported_indexes; +} + +jint Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getStyleIndexImpl(JNIEnv* env, + jobject j_obj, + jint j_player_index) { + return static_cast(EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetNpadStyleIndex(true)); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_setStyleIndexImpl(JNIEnv* env, + jobject j_obj, + jint j_player_index, + jint j_style_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + auto type = static_cast(j_style_index); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetNpadStyleIndex(type); + }); + if (j_player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + ConnectController(j_player_index, + player_one->IsConnected(true) || handheld->IsConnected(true)); + } +} + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_isControllerImpl(JNIEnv* env, + jobject j_obj, + jstring jparams) { + return static_cast(EmulationSession::GetInstance().GetInputSubsystem().IsController( + Common::ParamPackage(Common::Android::GetJString(env, jparams)))); +} + +jboolean Java_org_yuzu_yuzu_1emu_features_input_NativeInput_getIsConnected(JNIEnv* env, + jobject j_obj, + jint j_player_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + auto* controller = hid_core.GetEmulatedControllerByIndex(static_cast(j_player_index)); + if (j_player_index == 0 && + controller->GetNpadStyleIndex(true) == Core::HID::NpadStyleIndex::Handheld) { + return hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld)->IsConnected(true); + } + return controller->IsConnected(true); +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_connectControllersImpl( + JNIEnv* env, jobject j_obj, jbooleanArray j_connected) { + jboolean isCopy = false; + auto j_connected_array_size = env->GetArrayLength(j_connected); + jboolean* j_connected_array = env->GetBooleanArrayElements(j_connected, &isCopy); + for (int i = 0; i < j_connected_array_size; ++i) { + ConnectController(i, j_connected_array[i]); + } +} + +void Java_org_yuzu_yuzu_1emu_features_input_NativeInput_resetControllerMappings( + JNIEnv* env, jobject j_obj, jint j_player_index) { + // Clear all previous mappings + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(button_id, {}); + }); + } + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(analog_id, {}); + }); + } +} + +} // extern "C" diff --git a/src/android/app/src/main/res/drawable/button_anim.xml b/src/android/app/src/main/res/drawable/button_anim.xml new file mode 100644 index 000000000..ccdc5ca6a --- /dev/null +++ b/src/android/app/src/main/res/drawable/button_anim.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_controller_disconnected.xml b/src/android/app/src/main/res/drawable/ic_controller_disconnected.xml new file mode 100644 index 000000000..8e3c66f74 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_controller_disconnected.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_more_vert.xml b/src/android/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000..9f62ac595 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_new_label.xml b/src/android/app/src/main/res/drawable/ic_new_label.xml new file mode 100644 index 000000000..fac562c26 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_new_label.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_overlay.xml b/src/android/app/src/main/res/drawable/ic_overlay.xml new file mode 100644 index 000000000..c7986c5a2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_overlay.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_share.xml b/src/android/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..3fc2f3c99 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/stick_one_direction_anim.xml b/src/android/app/src/main/res/drawable/stick_one_direction_anim.xml new file mode 100644 index 000000000..a1da1316f --- /dev/null +++ b/src/android/app/src/main/res/drawable/stick_one_direction_anim.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/stick_two_direction_anim.xml b/src/android/app/src/main/res/drawable/stick_two_direction_anim.xml new file mode 100644 index 000000000..bc71adcbd --- /dev/null +++ b/src/android/app/src/main/res/drawable/stick_two_direction_anim.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout-ldrtl/list_item_setting_input.xml b/src/android/app/src/main/res/layout-ldrtl/list_item_setting_input.xml new file mode 100644 index 000000000..583620dc6 --- /dev/null +++ b/src/android/app/src/main/res/layout-ldrtl/list_item_setting_input.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + +