diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt index 8ecd60684..50f693b42 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt @@ -16,6 +16,7 @@ import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R import org.citra.citra_emu.features.hotkeys.Hotkey import org.citra.citra_emu.features.settings.model.AbstractSetting +import org.citra.citra_emu.features.settings.model.AbstractStringSetting import org.citra.citra_emu.features.settings.model.Settings class InputBindingSetting( @@ -329,5 +330,22 @@ class InputBindingSetting( event.keyCode } } + + fun getInputObject(key: String, preferences: SharedPreferences): AbstractStringSetting { + return object : AbstractStringSetting { + override var string: String + get() = preferences.getString(key, "")!! + set(value) { + preferences.edit() + .putString(key, value) + .apply() + } + override val key = key + override val section = Settings.SECTION_CONTROLS + override val isRuntimeEditable = true + override val valueAsString = preferences.getString(key, "")!! + override val defaultValue = "" + } + } } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerAutomappingDialog.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerAutomappingDialog.kt new file mode 100644 index 000000000..be3fc4db0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerAutomappingDialog.kt @@ -0,0 +1,241 @@ +package org.citra.citra_emu.features.settings.ui + +import android.app.AlertDialog +import android.content.Context +import android.content.SharedPreferences +import android.graphics.drawable.Drawable +import android.view.InputDevice +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import androidx.core.content.ContextCompat +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogControllerautomappingBinding +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting +import kotlin.math.abs + + +class ControllerAutomappingDialog( + private var context: Context, + buttons: ArrayList>, + titles: ArrayList>, + private var preferences: SharedPreferences +) { + + private var index = 0 + val inflater = LayoutInflater.from(context) + val automappingBinding = DialogControllerautomappingBinding.inflate(inflater) + var dialog: AlertDialog? = null + + var allButtons = arrayListOf() + var allTitles = arrayListOf() + + init { + buttons.forEach {group -> + group.forEach {button -> + allButtons.add(button) + } + } + titles.forEach {group -> + group.forEach {title -> + allTitles.add(title) + } + } + + } + + fun show() { + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + builder + .setView(automappingBinding.root) + .setTitle("Automapper") + .setPositiveButton("Next") {_,_ -> } + .setNegativeButton("Close") { dialog, which -> + dialog.dismiss() + } + + dialog = builder.create() + dialog?.show() + + dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } + automappingBinding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) } + + // Prepare the first element + prepareUIforIndex(index) + + val nextButton = dialog?.getButton(AlertDialog.BUTTON_POSITIVE) + nextButton?.setOnClickListener { + // Skip to next: + prepareUIforIndex(index++) + } + + } + + private fun prepareUIforIndex(i: Int) { + if (allButtons.size-1 < i) { + dialog?.dismiss() + return + } + + if(index>0) { + automappingBinding.lastMappingIcon.visibility = View.VISIBLE + automappingBinding.lastMappingDescription.visibility = View.VISIBLE + } + + val currentButton = allButtons[i] + val currentTitleInt = allTitles[i] + + val button = InputBindingSetting.getInputObject(currentButton, preferences) + + var lastTitle = setting?.value ?: "" + if(lastTitle.isBlank()) { + lastTitle = context.getString(R.string.automapping_unassigned) + } + automappingBinding.lastMappingDescription.text = lastTitle + automappingBinding.lastMappingIcon.setImageDrawable(automappingBinding.currentMappingIcon.drawable) + setting = InputBindingSetting(button, currentTitleInt) + + automappingBinding.currentMappingTitle.text = calculateTitle() + automappingBinding.currentMappingDescription.text = setting?.value + automappingBinding.currentMappingIcon.setImageDrawable(getIcon()) + + + if (allButtons.size-1 < index) { + dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text = + context.getString(R.string.automapping_dialog_finish) + dialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE + } + + } + + private fun calculateTitle(): String { + + val inputTypeId = when { + setting!!.isCirclePad() -> R.string.controller_circlepad + setting!!.isCStick() -> R.string.controller_c + setting!!.isDPad() -> R.string.controller_dpad + setting!!.isTrigger() -> R.string.controller_trigger + else -> R.string.button + } + + val nameId = setting?.nameId?.let { context.getString(it) } + + return String.format( + context.getString(R.string.input_dialog_title), + context.getString(inputTypeId), + nameId + ) + } + + private fun getIcon(): Drawable? { + val id = when { + setting!!.isCirclePad() -> R.drawable.stick_main + setting!!.isCStick() -> R.drawable.stick_c + setting!!.isDPad() -> R.drawable.dpad + else -> { + val resourceTitle = context.resources.getResourceEntryName(setting!!.nameId) + if(resourceTitle.startsWith("direction")) { + R.drawable.dpad + } else { + context.resources.getIdentifier(resourceTitle, "drawable", context.packageName) + } + } + } + return ContextCompat.getDrawable(context, id) + } + + private val previousValues = ArrayList() + private var prevDeviceId = 0 + private var waitingForEvent = true + private var setting: InputBindingSetting? = null + + + private var debounceTimestamp = System.currentTimeMillis() + + + private fun onKeyEvent(event: KeyEvent): Boolean { + return when (event.action) { + KeyEvent.ACTION_UP -> { + if(System.currentTimeMillis()-debounceTimestamp < 500) { + return true + } + + debounceTimestamp = System.currentTimeMillis() + + index++ + setting?.onKeyInput(event) + prepareUIforIndex(index) + // Even if we ignore the key, we still consume it. Thus return true regardless. + true + } + + else -> false + } + } + + private fun onMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) return false + if (event.action != MotionEvent.ACTION_MOVE) return false + + val input = event.device + + val motionRanges = input.motionRanges + + if (input.id != prevDeviceId) { + previousValues.clear() + } + prevDeviceId = input.id + val firstEvent = previousValues.isEmpty() + + var numMovedAxis = 0 + var axisMoveValue = 0.0f + var lastMovedRange: InputDevice.MotionRange? = null + var lastMovedDir = '?' + if (waitingForEvent) { + for (i in motionRanges.indices) { + val range = motionRanges[i] + val axis = range.axis + val origValue = event.getAxisValue(axis) + if (firstEvent) { + previousValues.add(origValue) + } else { + val previousValue = previousValues[i] + + // Only handle the axes that are not neutral (more than 0.5) + // but ignore any axis that has a constant value (e.g. always 1) + if (abs(origValue) > 0.5f && origValue != previousValue) { + // It is common to have multiple axes with the same physical input. For example, + // shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE. + // To handle this, we ignore an axis motion that's the exact same as a motion + // we already saw. This way, we ignore axes with two names, but catch the case + // where a joystick is moved in two directions. + // ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html + if (origValue != axisMoveValue) { + axisMoveValue = origValue + numMovedAxis++ + lastMovedRange = range + lastMovedDir = if (origValue < 0.0f) '-' else '+' + } + } else if (abs(origValue) < 0.25f && abs(previousValue) > 0.75f) { + // Special case for d-pads (axis value jumps between 0 and 1 without any values + // in between). Without this, the user would need to press the d-pad twice + // due to the first press being caught by the "if (firstEvent)" case further up. + numMovedAxis++ + lastMovedRange = range + lastMovedDir = if (previousValue < 0.0f) '-' else '+' + } + } + previousValues[i] = origValue + } + + // If only one axis moved, that's the winner. + if (numMovedAxis == 1) { + waitingForEvent = false + setting?.onMotionInput(input, lastMovedRange!!, lastMovedDir) + } + } + return true + } + +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt index 71075b40c..f9b83fb4c 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt @@ -44,6 +44,7 @@ import org.citra.citra_emu.features.settings.model.AbstractStringSetting import org.citra.citra_emu.features.settings.model.FloatSetting import org.citra.citra_emu.features.settings.model.ScaledFloatSetting import org.citra.citra_emu.features.settings.model.AbstractShortSetting +import org.citra.citra_emu.features.settings.model.Settings import org.citra.citra_emu.features.settings.model.view.DateTimeSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting import org.citra.citra_emu.features.settings.model.view.SettingsItem @@ -65,6 +66,7 @@ import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder import org.citra.citra_emu.features.settings.ui.viewholder.SwitchSettingViewHolder import org.citra.citra_emu.fragments.MessageDialogFragment import org.citra.citra_emu.fragments.MotionBottomSheetDialogFragment +import org.citra.citra_emu.utils.PermissionsHandler.preferences import org.citra.citra_emu.utils.SystemSaveGame import java.lang.IllegalStateException import java.lang.NumberFormatException @@ -514,6 +516,32 @@ class SettingsAdapter( .show() } + fun onClickAutoconfigureControls() { + + val buttons = arrayListOf( + Settings.buttonKeys, + Settings.circlePadKeys, + Settings.cStickKeys, + Settings.dPadAxisKeys, + Settings.dPadButtonKeys, + Settings.triggerKeys + ) + + val titles = arrayListOf( + Settings.buttonTitles, + Settings.axisTitles, + Settings.axisTitles, + Settings.axisTitles, + Settings.dPadTitles, + Settings.triggerTitles + ) + + Settings.buttonTitles + ControllerAutomappingDialog(context, buttons, titles, preferences).show() + + + } + fun closeDialog() { if (dialog != null) { if (clickedPosition != -1) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt index 31401567f..091643bf0 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -646,44 +646,56 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) private fun addControlsSettings(sl: ArrayList) { settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_controls)) sl.apply { + add(HeaderSetting(R.string.auto_configure)) + + add( + RunnableSetting( + R.string.auto_configure, + 0, + false, + 0, + { settingsAdapter.onClickAutoconfigureControls() } + ) + ) + add(HeaderSetting(R.string.generic_buttons)) Settings.buttonKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.buttonTitles[i])) } add(HeaderSetting(R.string.controller_circlepad)) Settings.circlePadKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_c)) Settings.cStickKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_dpad_axis,R.string.controller_dpad_axis_description)) Settings.dPadAxisKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_dpad_button,R.string.controller_dpad_button_description)) Settings.dPadButtonKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.dPadTitles[i])) } add(HeaderSetting(R.string.controller_triggers)) Settings.triggerKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.triggerTitles[i])) } add(HeaderSetting(R.string.controller_hotkeys)) Settings.hotKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.hotkeyTitles[i])) } add(HeaderSetting(R.string.miscellaneous)) @@ -699,23 +711,6 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) } } - private fun getInputObject(key: String): AbstractStringSetting { - return object : AbstractStringSetting { - override var string: String - get() = preferences.getString(key, "")!! - set(value) { - preferences.edit() - .putString(key, value) - .apply() - } - override val key = key - override val section = Settings.SECTION_CONTROLS - override val isRuntimeEditable = true - override val valueAsString = preferences.getString(key, "")!! - override val defaultValue = "" - } - } - private fun addGraphicsSettings(sl: ArrayList) { settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics)) sl.apply { diff --git a/src/android/app/src/main/res/layout/dialog_controllerautomapping.xml b/src/android/app/src/main/res/layout/dialog_controllerautomapping.xml new file mode 100644 index 000000000..9dd0160ed --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_controllerautomapping.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 5495a2a23..9cc19ab80 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -169,6 +169,7 @@ Due to how slow Android\'s storage access framework is for accessing Azahar\'s files, downloading multiple versions of system files can dramatically slow down loading for applications, save states, and the Applications list. Only download the files that you require to avoid any issues with loading speeds. + Auto Configuration Buttons Button @@ -765,6 +766,8 @@ Enter Artic Base server address Delay game render thread Delay the game render thread when it submits data to the GPU. Helps with performance issues in the (very few) applications with dynamic framerates. + Finish + Unassigned Quicksave