// SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-FileCopyrightText: 2024 suyu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package dev.suyu.suyu_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 dev.suyu.suyu_emu.R import dev.suyu.suyu_emu.databinding.DialogMappingBinding import dev.suyu.suyu_emu.features.input.NativeInput import dev.suyu.suyu_emu.features.input.model.NativeAnalog import dev.suyu.suyu_emu.features.input.model.NativeButton import dev.suyu.suyu_emu.features.settings.model.view.AnalogInputSetting import dev.suyu.suyu_emu.features.settings.model.view.ButtonInputSetting import dev.suyu.suyu_emu.features.settings.model.view.InputSetting import dev.suyu.suyu_emu.features.settings.model.view.ModifierInputSetting import dev.suyu.suyu_emu.utils.InputHandler import dev.suyu.suyu_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 } } }