From 75d485a9a76e92a414665593284226f8096e9919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=97=B1=20PixelyIon?= Date: Thu, 28 May 2020 19:27:25 +0000 Subject: [PATCH] Addition of Controller Configuration UI This commit adds in the UI for Controller Configuration to Settings, in addition to introducing the storage and loading of aforementioned configurations to a file that can be saved/loaded at runtime. This commit also fixes updating of individual fields in Settings when changed from an external activity. --- .idea/inspectionProfiles/Project_Default.xml | 3 +- .../Controller_Configuration.xml | 53 ++ .idea/runConfigurations/Setting.xml | 53 ++ .idea/scopes/SkylineXml.xml | 3 + app/src/main/AndroidManifest.xml | 16 + .../main/cpp/skyline/services/serviceman.cpp | 2 +- .../java/emu/skyline/EmulationActivity.kt | 62 +- .../main/java/emu/skyline/SettingsActivity.kt | 36 +- .../java/emu/skyline/adapter/AppAdapter.kt | 12 +- .../emu/skyline/adapter/ControllerAdapter.kt | 228 +++++++ .../java/emu/skyline/adapter/HeaderAdapter.kt | 11 +- .../java/emu/skyline/adapter/LogAdapter.kt | 12 +- .../main/java/emu/skyline/data/BaseItem.kt | 2 +- app/src/main/java/emu/skyline/input/Button.kt | 21 - .../main/java/emu/skyline/input/Controller.kt | 72 +++ .../emu/skyline/input/ControllerActivity.kt | 256 ++++++++ .../main/java/emu/skyline/input/GuestEvent.kt | 156 +++++ .../main/java/emu/skyline/input/HostEvent.kt | 69 ++ .../java/emu/skyline/input/InputManager.kt | 108 ++++ app/src/main/java/emu/skyline/input/Npad.kt | 57 -- .../emu/skyline/input/dialog/ButtonDialog.kt | 240 +++++++ .../emu/skyline/input/dialog/RumbleDialog.kt | 123 ++++ .../emu/skyline/input/dialog/StickDialog.kt | 605 ++++++++++++++++++ .../preference/ControllerPreference.kt | 64 ++ .../skyline/preference/FolderPreference.kt | 22 +- .../main/res/drawable-night/ic_controller.xml | 9 + app/src/main/res/drawable/ic_button.xml | 8 + app/src/main/res/drawable/ic_controller.xml | 9 + app/src/main/res/drawable/ic_stick.xml | 32 + app/src/main/res/drawable/ic_stick_circle.xml | 8 + app/src/main/res/layout/button_dialog.xml | 80 +++ .../main/res/layout/controller_activity.xml | 17 + app/src/main/res/layout/controller_item.xml | 30 + app/src/main/res/layout/rumble_dialog.xml | 75 +++ app/src/main/res/layout/stick_dialog.xml | 125 ++++ app/src/main/res/values/strings.xml | 69 +- app/src/main/res/values/styles.xml | 5 + app/src/main/res/xml/preferences.xml | 38 +- 38 files changed, 2614 insertions(+), 177 deletions(-) create mode 100644 .idea/runConfigurations/Controller_Configuration.xml create mode 100644 .idea/runConfigurations/Setting.xml create mode 100644 .idea/scopes/SkylineXml.xml create mode 100644 app/src/main/java/emu/skyline/adapter/ControllerAdapter.kt delete mode 100644 app/src/main/java/emu/skyline/input/Button.kt create mode 100644 app/src/main/java/emu/skyline/input/Controller.kt create mode 100644 app/src/main/java/emu/skyline/input/ControllerActivity.kt create mode 100644 app/src/main/java/emu/skyline/input/GuestEvent.kt create mode 100644 app/src/main/java/emu/skyline/input/HostEvent.kt create mode 100644 app/src/main/java/emu/skyline/input/InputManager.kt delete mode 100644 app/src/main/java/emu/skyline/input/Npad.kt create mode 100644 app/src/main/java/emu/skyline/input/dialog/ButtonDialog.kt create mode 100644 app/src/main/java/emu/skyline/input/dialog/RumbleDialog.kt create mode 100644 app/src/main/java/emu/skyline/input/dialog/StickDialog.kt create mode 100644 app/src/main/java/emu/skyline/preference/ControllerPreference.kt create mode 100644 app/src/main/res/drawable-night/ic_controller.xml create mode 100644 app/src/main/res/drawable/ic_button.xml create mode 100644 app/src/main/res/drawable/ic_controller.xml create mode 100644 app/src/main/res/drawable/ic_stick.xml create mode 100644 app/src/main/res/drawable/ic_stick_circle.xml create mode 100644 app/src/main/res/layout/button_dialog.xml create mode 100644 app/src/main/res/layout/controller_activity.xml create mode 100644 app/src/main/res/layout/controller_item.xml create mode 100644 app/src/main/res/layout/rumble_dialog.xml create mode 100644 app/src/main/res/layout/stick_dialog.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index eccdeea2..dde74879 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -349,6 +349,7 @@ + @@ -2479,7 +2480,7 @@ - + diff --git a/.idea/runConfigurations/Controller_Configuration.xml b/.idea/runConfigurations/Controller_Configuration.xml new file mode 100644 index 00000000..2770de74 --- /dev/null +++ b/.idea/runConfigurations/Controller_Configuration.xml @@ -0,0 +1,53 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Setting.xml b/.idea/runConfigurations/Setting.xml new file mode 100644 index 00000000..8d90e67a --- /dev/null +++ b/.idea/runConfigurations/Setting.xml @@ -0,0 +1,53 @@ + + + + + \ No newline at end of file diff --git a/.idea/scopes/SkylineXml.xml b/.idea/scopes/SkylineXml.xml new file mode 100644 index 00000000..b4d317e8 --- /dev/null +++ b/.idea/scopes/SkylineXml.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a29a4b8f..a1380a27 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,11 +2,14 @@ + + + + + + + + + + serviceStatus = type::KSession::ServiceStatus::Closed; } - }; + } void ServiceManager::SyncRequestHandler(KHandle handle) { auto session = state.process->GetHandle(handle); diff --git a/app/src/main/java/emu/skyline/EmulationActivity.kt b/app/src/main/java/emu/skyline/EmulationActivity.kt index a6abfd10..81714724 100644 --- a/app/src/main/java/emu/skyline/EmulationActivity.kt +++ b/app/src/main/java/emu/skyline/EmulationActivity.kt @@ -15,9 +15,9 @@ import android.util.Log import android.view.* import androidx.appcompat.app.AppCompatActivity import androidx.preference.PreferenceManager +import emu.skyline.input.AxisId +import emu.skyline.input.ButtonId import emu.skyline.input.ButtonState -import emu.skyline.input.NpadAxis -import emu.skyline.input.NpadButton import emu.skyline.loader.getRomFormat import kotlinx.android.synthetic.main.emu_activity.* import java.io.File @@ -231,26 +231,26 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback { else -> return false } - val buttonMap : Map = mapOf( - KeyEvent.KEYCODE_BUTTON_A to NpadButton.A, - KeyEvent.KEYCODE_BUTTON_B to NpadButton.B, - KeyEvent.KEYCODE_BUTTON_X to NpadButton.X, - KeyEvent.KEYCODE_BUTTON_Y to NpadButton.Y, - KeyEvent.KEYCODE_BUTTON_THUMBL to NpadButton.LeftStick, - KeyEvent.KEYCODE_BUTTON_THUMBR to NpadButton.RightStick, - KeyEvent.KEYCODE_BUTTON_L1 to NpadButton.L, - KeyEvent.KEYCODE_BUTTON_R1 to NpadButton.R, - KeyEvent.KEYCODE_BUTTON_L2 to NpadButton.ZL, - KeyEvent.KEYCODE_BUTTON_R2 to NpadButton.ZR, - KeyEvent.KEYCODE_BUTTON_START to NpadButton.Plus, - KeyEvent.KEYCODE_BUTTON_SELECT to NpadButton.Minus, - KeyEvent.KEYCODE_DPAD_DOWN to NpadButton.DpadDown, - KeyEvent.KEYCODE_DPAD_UP to NpadButton.DpadUp, - KeyEvent.KEYCODE_DPAD_LEFT to NpadButton.DpadLeft, - KeyEvent.KEYCODE_DPAD_RIGHT to NpadButton.DpadRight) + val buttonMap : Map = mapOf( + KeyEvent.KEYCODE_BUTTON_A to ButtonId.A, + KeyEvent.KEYCODE_BUTTON_B to ButtonId.B, + KeyEvent.KEYCODE_BUTTON_X to ButtonId.X, + KeyEvent.KEYCODE_BUTTON_Y to ButtonId.Y, + KeyEvent.KEYCODE_BUTTON_THUMBL to ButtonId.LeftStick, + KeyEvent.KEYCODE_BUTTON_THUMBR to ButtonId.RightStick, + KeyEvent.KEYCODE_BUTTON_L1 to ButtonId.L, + KeyEvent.KEYCODE_BUTTON_R1 to ButtonId.R, + KeyEvent.KEYCODE_BUTTON_L2 to ButtonId.ZL, + KeyEvent.KEYCODE_BUTTON_R2 to ButtonId.ZR, + KeyEvent.KEYCODE_BUTTON_START to ButtonId.Plus, + KeyEvent.KEYCODE_BUTTON_SELECT to ButtonId.Minus, + KeyEvent.KEYCODE_DPAD_DOWN to ButtonId.DpadDown, + KeyEvent.KEYCODE_DPAD_UP to ButtonId.DpadUp, + KeyEvent.KEYCODE_DPAD_LEFT to ButtonId.DpadLeft, + KeyEvent.KEYCODE_DPAD_RIGHT to ButtonId.DpadRight) return try { - setButtonState(buttonMap.getValue(event.keyCode).value(), action.ordinal); + setButtonState(buttonMap.getValue(event.keyCode).value(), action.ordinal) true } catch (ignored : NoSuchElementException) { super.dispatchKeyEvent(event) @@ -273,13 +273,13 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback { override fun dispatchGenericMotionEvent(event : MotionEvent) : Boolean { if ((event.source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD || (event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) { - val hatXMap : Map = mapOf( - -1.0f to NpadButton.DpadLeft, - +1.0f to NpadButton.DpadRight) + val hatXMap : Map = mapOf( + -1.0f to ButtonId.DpadLeft, + +1.0f to ButtonId.DpadRight) - val hatYMap : Map = mapOf( - -1.0f to NpadButton.DpadUp, - +1.0f to NpadButton.DpadDown) + val hatYMap : Map = mapOf( + -1.0f to ButtonId.DpadUp, + +1.0f to ButtonId.DpadDown) if (controllerHatX != event.getAxisValue(MotionEvent.AXIS_HAT_X)) { if (event.getAxisValue(MotionEvent.AXIS_HAT_X) == 0.0f) @@ -305,11 +305,11 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback { } if ((event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && event.action == MotionEvent.ACTION_MOVE) { - val axisMap : Map = mapOf( - MotionEvent.AXIS_X to NpadAxis.LX, - MotionEvent.AXIS_Y to NpadAxis.LY, - MotionEvent.AXIS_Z to NpadAxis.RX, - MotionEvent.AXIS_RZ to NpadAxis.RY) + val axisMap : Map = mapOf( + MotionEvent.AXIS_X to AxisId.LX, + MotionEvent.AXIS_Y to AxisId.LY, + MotionEvent.AXIS_Z to AxisId.RX, + MotionEvent.AXIS_RZ to AxisId.RY) //TODO: Digital inputs based off of analog sticks event.device.motionRanges.forEach { diff --git a/app/src/main/java/emu/skyline/SettingsActivity.kt b/app/src/main/java/emu/skyline/SettingsActivity.kt index a74f1081..327a8413 100644 --- a/app/src/main/java/emu/skyline/SettingsActivity.kt +++ b/app/src/main/java/emu/skyline/SettingsActivity.kt @@ -9,7 +9,10 @@ import android.content.Intent import android.os.Bundle import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import emu.skyline.input.InputManager +import emu.skyline.preference.ControllerPreference import kotlinx.android.synthetic.main.titlebar.* class SettingsActivity : AppCompatActivity() { @@ -19,11 +22,23 @@ class SettingsActivity : AppCompatActivity() { private val preferenceFragment : PreferenceFragment = PreferenceFragment() /** - * This initializes [toolbar] and [R.id.settings] + * This is an instance of [InputManager] used by [ControllerPreference] + */ + lateinit var inputManager : InputManager + + /** + * The key of the element to force a refresh when [onActivityResult] is called + */ + var refreshKey : String? = null + + /** + * This initializes all of the elements in the activity and displays the settings fragment */ override fun onCreate(savedInstanceState : Bundle?) { super.onCreate(savedInstanceState) + inputManager = InputManager(this) + setContentView(R.layout.settings_activity) setSupportActionBar(toolbar) @@ -36,11 +51,17 @@ class SettingsActivity : AppCompatActivity() { } /** - * This is used to refresh the preferences after [emu.skyline.preference.FolderActivity] has returned + * This is used to refresh the preferences after [emu.skyline.preference.FolderActivity] or [emu.skyline.input.ControllerActivity] has returned */ public override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) { super.onActivityResult(requestCode, resultCode, data) - preferenceFragment.refreshPreferences() + + if (refreshKey != null) { + inputManager.syncObjects() + preferenceFragment.refreshPreference(refreshKey!!) + + refreshKey = null + } } /** @@ -48,11 +69,12 @@ class SettingsActivity : AppCompatActivity() { */ class PreferenceFragment : PreferenceFragmentCompat() { /** - * This clears the preference screen and reloads all preferences + * This forces refreshing a certain preference by indirectly calling [Preference.notifyChanged] */ - fun refreshPreferences() { - preferenceScreen = null - addPreferencesFromResource(R.xml.preferences) + fun refreshPreference(key : String) { + val preference = preferenceManager.findPreference(key)!! + preference.isSelectable = !preference.isSelectable + preference.isSelectable = !preference.isSelectable } /** diff --git a/app/src/main/java/emu/skyline/adapter/AppAdapter.kt b/app/src/main/java/emu/skyline/adapter/AppAdapter.kt index 025dc007..35480f24 100644 --- a/app/src/main/java/emu/skyline/adapter/AppAdapter.kt +++ b/app/src/main/java/emu/skyline/adapter/AppAdapter.kt @@ -98,14 +98,12 @@ internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : In } /** - * This function binds the item at [position] to the supplied [viewHolder] + * This function binds the item at [position] to the supplied [holder] */ - override fun onBindViewHolder(viewHolder : RecyclerView.ViewHolder, position : Int) { + override fun onBindViewHolder(holder : RecyclerView.ViewHolder, position : Int) { val item = getItem(position) - if (item is AppItem) { - val holder = viewHolder as ItemViewHolder - + if (item is AppItem && holder is ItemViewHolder) { holder.title.text = item.title holder.subtitle.text = item.subTitle ?: missingString @@ -122,9 +120,7 @@ internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : In setOnClickListener { onClick.invoke(item) } setOnLongClickListener { true.also { onLongClick.invoke(item) } } } - } else if (item is BaseHeader) { - val holder = viewHolder as HeaderViewHolder - + } else if (item is BaseHeader && holder is HeaderViewHolder) { holder.header!!.text = item.title } } diff --git a/app/src/main/java/emu/skyline/adapter/ControllerAdapter.kt b/app/src/main/java/emu/skyline/adapter/ControllerAdapter.kt new file mode 100644 index 00000000..1ba242ee --- /dev/null +++ b/app/src/main/java/emu/skyline/adapter/ControllerAdapter.kt @@ -0,0 +1,228 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import emu.skyline.R +import emu.skyline.data.BaseItem +import emu.skyline.input.* + +/** + * This is a class that holds everything relevant to a single item in the controller configuration list + * + * @param content The main line of text describing what the item is + * @param subContent The secondary line of text to show data more specific data about the item + */ +abstract class ControllerItem(var content : String, var subContent : String) : BaseItem() { + /** + * The underlying adapter this item is contained within + */ + var adapter : ControllerAdapter? = null + + /** + * The position of this item in the adapter + */ + var position : Int? = null + + /** + * This function updates the visible contents of the item + */ + fun update(content : String?, subContent : String?) { + if (content != null) + this.content = content + + if (subContent != null) + this.subContent = subContent + + position?.let { adapter?.notifyItemChanged(it) } + } + + /** + * This is used as a generic function to update the contents of the item + */ + abstract fun update() +} + +/** + * This item is used to display the [type] of the currently active controller + */ +class ControllerTypeItem(val context : Context, val type : ControllerType) : ControllerItem(context.getString(R.string.controller_type), context.getString(type.stringRes)) { + /** + * This function just updates [subContent] based on [type] + */ + override fun update() = update(null, context.getString(type.stringRes)) +} + +/** + * This item is used to display general settings items regarding controller + * + * @param type The type of controller setting this item is displaying + */ +class ControllerGeneralItem(val context : ControllerActivity, val type : GeneralType) : ControllerItem(context.getString(type.stringRes), getSummary(context, type)) { + companion object { + /** + * This returns the summary for [type] by using data encapsulated within [Controller] + */ + fun getSummary(context : ControllerActivity, type : GeneralType) : String { + val controller = context.manager.controllers[context.id]!! + + return when (type) { + GeneralType.PartnerJoyCon -> { + val partner = (controller as JoyConLeftController).partnerId + + if (partner != null) + "${context.getString(R.string.controller)} #${partner + 1}" + else + context.getString(R.string.none) + } + GeneralType.RumbleDevice -> controller.rumbleDevice?.second ?: context.getString(R.string.none) + } + } + } + + /** + * This function just updates [subContent] based on [getSummary] + */ + override fun update() = update(null, getSummary(context, type)) +} + +/** + * This item is used to display a particular [button] mapping for the controller + */ +class ControllerButtonItem(val context : ControllerActivity, val button : ButtonId) : ControllerItem(button.long?.let { context.getString(it) } ?: button.toString(), getSummary(context, button)) { + companion object { + /** + * This returns the summary for [button] by doing a reverse-lookup in [InputManager.eventMap] + */ + fun getSummary(context : ControllerActivity, button : ButtonId) : String { + val guestEvent = ButtonGuestEvent(context.id, button) + return context.manager.eventMap.filter { it.value is ButtonGuestEvent && it.value == guestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none) + } + } + + /** + * This function just updates [subContent] based on [getSummary] + */ + override fun update() = update(null, getSummary(context, button)) +} + +/** + * This item is used to display all information regarding a [stick] and it's mappings for the controller + */ +class ControllerStickItem(val context : ControllerActivity, val stick : StickId) : ControllerItem(stick.toString(), getSummary(context, stick)) { + companion object { + /** + * This returns the summary for [stick] by doing reverse-lookups in [InputManager.eventMap] + */ + fun getSummary(context : ControllerActivity, stick : StickId) : String { + val buttonGuestEvent = ButtonGuestEvent(context.id, stick.button) + val button = context.manager.eventMap.filter { it.value is ButtonGuestEvent && it.value == buttonGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none) + + var axisGuestEvent = AxisGuestEvent(context.id, stick.yAxis, true) + val yAxisPlus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none) + + axisGuestEvent = AxisGuestEvent(context.id, stick.yAxis, false) + val yAxisMinus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none) + + axisGuestEvent = AxisGuestEvent(context.id, stick.xAxis, true) + val xAxisPlus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none) + + axisGuestEvent = AxisGuestEvent(context.id, stick.xAxis, false) + val xAxisMinus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none) + + return "${context.getString(R.string.button)}: $button\n${context.getString(R.string.up)}: $yAxisPlus\n${context.getString(R.string.down)}: $yAxisMinus\n${context.getString(R.string.left)}: $xAxisMinus\n${context.getString(R.string.right)}: $xAxisPlus" + } + } + + /** + * This function just updates [subContent] based on [getSummary] + */ + override fun update() = update(null, getSummary(context, stick)) +} + +/** + * This adapter is used to create a list which handles having a simple view + */ +class ControllerAdapter(val context : Context) : HeaderAdapter() { + /** + * This adds a header to the view with the contents of [string] + */ + fun addHeader(string : String) { + super.addHeader(BaseHeader(string)) + } + + /** + * This functions sets [ControllerItem.adapter] and delegates the call to [HeaderAdapter.addItem] + */ + fun addItem(item : ControllerItem) { + item.adapter = this + super.addItem(item) + } + + /** + * The ViewHolder used by items is used to hold the views associated with an item + * + * @param parent The parent view that contains all the others + * @param title The TextView associated with the title + * @param subtitle The TextView associated with the subtitle + * @param item The View containing the two other views + */ + class ItemViewHolder(val parent : View, var title : TextView, var subtitle : TextView, var item : View) : RecyclerView.ViewHolder(parent) + + /** + * The ViewHolder used by headers is used to hold the views associated with an headers + * + * @param parent The parent view that contains all the others + * @param header The TextView associated with the header + */ + private class HeaderViewHolder(val parent : View, var header : TextView? = null) : RecyclerView.ViewHolder(parent) + + /** + * This function creates the view-holder of type [viewType] with the layout parent as [parent] + */ + override fun onCreateViewHolder(parent : ViewGroup, viewType : Int) : RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(context) + var holder : RecyclerView.ViewHolder? = null + + if (viewType == ElementType.Item.ordinal) { + val view = inflater.inflate(R.layout.controller_item, parent, false) + holder = ItemViewHolder(view, view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle), view.findViewById(R.id.controller_item)) + + if (context is View.OnClickListener) + holder.item.setOnClickListener(context as View.OnClickListener) + } else if (viewType == ElementType.Header.ordinal) { + val view = inflater.inflate(R.layout.section_item, parent, false) + holder = HeaderViewHolder(view) + + holder.header = view.findViewById(R.id.text_title) + } + + return holder!! + } + + /** + * This function binds the item at [position] to the supplied [holder] + */ + override fun onBindViewHolder(holder : RecyclerView.ViewHolder, position : Int) { + val item = getItem(position) + + if (item is ControllerItem && holder is ItemViewHolder) { + item.position = position + + holder.title.text = item.content + holder.subtitle.text = item.subContent + + holder.parent.tag = item + } else if (item is BaseHeader && holder is HeaderViewHolder) { + holder.header?.text = item.title + } + } +} diff --git a/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt b/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt index 1d3b9600..5698fd21 100644 --- a/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt +++ b/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt @@ -17,13 +17,12 @@ import java.io.* import java.util.* import kotlin.collections.ArrayList - /** * An enumeration of the type of elements in this adapter */ -enum class ElementType(val type : Int) { - Header(0x0), - Item(0x1) +enum class ElementType { + Header, + Item, } /** @@ -132,7 +131,7 @@ abstract class HeaderAdapter = arrayOf(), val buttons : Array = arrayOf()) { + None(R.string.none, false), + HandheldProController(R.string.handheld_procon, true, arrayOf(StickId.Left, StickId.Right), arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y, ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight, ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR, ButtonId.Plus, ButtonId.Minus)), + ProController(R.string.procon, false, arrayOf(StickId.Left, StickId.Right), arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y, ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight, ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR, ButtonId.Plus, ButtonId.Minus)), + JoyConLeft(R.string.ljoycon, false, arrayOf(StickId.Left), arrayOf(ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight, ButtonId.L, ButtonId.ZL, ButtonId.Minus, ButtonId.LeftSL, ButtonId.LeftSR)), + JoyConRight(R.string.rjoycon, false, arrayOf(StickId.Right), arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y, ButtonId.R, ButtonId.ZR, ButtonId.Plus, ButtonId.RightSL, ButtonId.RightSR)), +} + +/** + * The enumerates the type of general settings for a Controller + * + * @param stringRes The string resource for the setting + * @param compatibleControllers An array of the types of compatible controllers + */ +enum class GeneralType(val stringRes : Int, val compatibleControllers : Array? = null) { + PartnerJoyCon(R.string.partner_joycon, arrayOf(ControllerType.JoyConLeft)), + RumbleDevice(R.string.rumble_device), +} + +/** + * This is the base class for all controllers, when controllers require to store more variables it'll be stored here + * + * @param id The ID of the controller + * @param type The type of the controller + * @param rumbleDevice The device descriptor and the name of the device rumble/force-feedback will be passed onto + */ +open class Controller(val id : Int, var type : ControllerType, var rumbleDevice : Pair? = null) : Serializable { + /** + * The current version of this class so that different versions won't be deserialized mistakenly + */ + private val serialVersionUID = 0L +} + +/** + * This Controller class is for the Handheld-ProCon controller that change based on the operation mode + */ +class HandheldController(id : Int) : Controller(id, ControllerType.HandheldProController) + +/** + * This Controller class is for the Pro Controller (ProCon) + */ +class ProController(id : Int) : Controller(id, ControllerType.ProController) + +/** + * This Controller class is for the left Joy-Con controller + * + * @param partnerId The ID of the corresponding right Joy-Con if this is a pair + */ +class JoyConLeftController(id : Int, var partnerId : Int? = null) : Controller(id, ControllerType.JoyConLeft) + +/** + * This Controller class is for the right Joy-Con controller + * + * @param partnerId The ID of the corresponding left Joy-Con if this is a pair + */ +class JoyConRightController(id : Int, var partnerId : Int? = null) : Controller(id, ControllerType.JoyConRight) diff --git a/app/src/main/java/emu/skyline/input/ControllerActivity.kt b/app/src/main/java/emu/skyline/input/ControllerActivity.kt new file mode 100644 index 00000000..45bcdb10 --- /dev/null +++ b/app/src/main/java/emu/skyline/input/ControllerActivity.kt @@ -0,0 +1,256 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.input + +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import emu.skyline.R +import emu.skyline.adapter.* +import emu.skyline.input.dialog.ButtonDialog +import emu.skyline.input.dialog.RumbleDialog +import emu.skyline.input.dialog.StickDialog +import kotlinx.android.synthetic.main.controller_activity.* +import kotlinx.android.synthetic.main.titlebar.* + +/** + * This activity is used to change the settings for a specific controller + */ +class ControllerActivity : AppCompatActivity(), View.OnClickListener { + /** + * The index of the controller this activity manages + */ + var id : Int = -1 + + /** + * The adapter used by [controller_list] to hold all the items + */ + val adapter = ControllerAdapter(this) + + /** + * The [InputManager] class handles loading/saving the input data + */ + lateinit var manager : InputManager + + /** + * This is a map between a button and it's corresponding [ControllerItem] in [adapter] + */ + val buttonMap = mutableMapOf() + + /** + * This is a map between an axis and it's corresponding [ControllerStickItem] in [adapter] + */ + val axisMap = mutableMapOf() + + /** + * This function updates the [adapter] based on information from [manager] + */ + private fun update() { + adapter.clear() + + val controller = manager.controllers[id]!! + + adapter.addItem(ControllerTypeItem(this, controller.type)) + + if (controller.type == ControllerType.None) + return + + var wroteTitle = false + + for (item in GeneralType.values()) { + if (item.compatibleControllers == null || item.compatibleControllers.contains(controller.type)) { + if (!wroteTitle) { + adapter.addHeader(getString(R.string.general)) + wroteTitle = true + } + + adapter.addItem(ControllerGeneralItem(this, item)) + } + } + + wroteTitle = false + + for (stick in controller.type.sticks) { + if (!wroteTitle) { + adapter.addHeader(getString(R.string.sticks)) + wroteTitle = true + } + + val stickItem = ControllerStickItem(this, stick) + + adapter.addItem(stickItem) + buttonMap[stick.button] = stickItem + axisMap[stick.xAxis] = stickItem + axisMap[stick.yAxis] = stickItem + } + + val dpadButtons = Pair(R.string.dpad, arrayOf(ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight)) + val faceButtons = Pair(R.string.face_buttons, arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y)) + val shoulderTriggerButtons = Pair(R.string.shoulder_trigger, arrayOf(ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR)) + val shoulderRailButtons = Pair(R.string.shoulder_rail, arrayOf(ButtonId.LeftSL, ButtonId.LeftSR, ButtonId.RightSL, ButtonId.RightSR)) + + val buttonArrays = arrayOf(dpadButtons, faceButtons, shoulderTriggerButtons, shoulderRailButtons) + + for (buttonArray in buttonArrays) { + wroteTitle = false + + for (button in controller.type.buttons.filter { it in buttonArray.second }) { + if (!wroteTitle) { + adapter.addHeader(getString(buttonArray.first)) + wroteTitle = true + } + + val buttonItem = ControllerButtonItem(this, button) + + adapter.addItem(buttonItem) + buttonMap[button] = buttonItem + } + } + + wroteTitle = false + + for (button in controller.type.buttons.filterNot { item -> buttonArrays.any { item in it.second } }.plus(ButtonId.Menu)) { + if (!wroteTitle) { + adapter.addHeader(getString(R.string.misc_buttons)) + wroteTitle = true + } + + val buttonItem = ControllerButtonItem(this, button) + + adapter.addItem(buttonItem) + buttonMap[button] = buttonItem + } + } + + /** + * This initializes all of the elements in the activity + */ + override fun onCreate(state : Bundle?) { + super.onCreate(state) + + manager = InputManager(this) + + id = intent.getIntExtra("index", 0) + + if (id < 0 || id > 7) + throw IllegalArgumentException() + + title = "${getString(R.string.config_controller)} #${id + 1}" + + setContentView(R.layout.controller_activity) + + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + window.decorView.findViewById(android.R.id.content).viewTreeObserver.addOnTouchModeChangeListener { + if (!it) + toolbar_layout.setExpanded(false) + } + + controller_list.layoutManager = LinearLayoutManager(this) + controller_list.adapter = adapter + + update() + } + + /** + * This causes the input file to be synced when the activity has been paused + */ + override fun onPause() { + manager.syncFile() + super.onPause() + } + + /** + * This handles the onClick events for the items in the activity + */ + override fun onClick(v : View?) { + when (val tag = v!!.tag) { + is ControllerTypeItem -> { + val type = manager.controllers[id]!!.type + + val types = ControllerType.values().filter { !it.firstController || id == 0 } + val typeNames = types.map { getString(it.stringRes) }.toTypedArray() + + MaterialAlertDialogBuilder(this) + .setTitle(tag.content) + .setSingleChoiceItems(typeNames, types.indexOf(type)) { dialog, typeIndex -> + manager.controllers[id] = when (types[typeIndex]) { + ControllerType.None -> Controller(id, ControllerType.None) + ControllerType.HandheldProController -> HandheldController(id) + ControllerType.ProController -> ProController(id) + ControllerType.JoyConLeft -> JoyConLeftController(id) + ControllerType.JoyConRight -> JoyConRightController(id) + } + + update() + + dialog.dismiss() + } + .show() + } + + is ControllerGeneralItem -> { + when (tag.type) { + GeneralType.PartnerJoyCon -> { + val controller = manager.controllers[id] as JoyConLeftController + + val rJoyCons = manager.controllers.values.filter { it.type == ControllerType.JoyConRight } + val rJoyConNames = (listOf(getString(R.string.none)) + rJoyCons.map { "${getString(R.string.controller)} #${it.id + 1}" }).toTypedArray() + + val partnerNameIndex = if (controller.partnerId == null) 0 else rJoyCons.withIndex().single { it.value.id == controller.partnerId }.index + 1 + + MaterialAlertDialogBuilder(this) + .setTitle(tag.content) + .setSingleChoiceItems(rJoyConNames, partnerNameIndex) { dialog, index -> + (manager.controllers[controller.partnerId ?: -1] as JoyConRightController?)?.partnerId = null + + controller.partnerId = if (index == 0) null else rJoyCons[index - 1].id + + if (controller.partnerId != null) + (manager.controllers[controller.partnerId ?: -1] as JoyConRightController?)?.partnerId = controller.id + + tag.update() + + dialog.dismiss() + } + .show() + } + + GeneralType.RumbleDevice -> { + val dialog = RumbleDialog(tag) + dialog.show(supportFragmentManager, null) + } + } + } + + is ControllerButtonItem -> { + val dialog = ButtonDialog(tag) + dialog.show(supportFragmentManager, null) + } + + is ControllerStickItem -> { + val dialog = StickDialog(tag) + dialog.show(supportFragmentManager, null) + } + } + } + + /** + * This handles on calling [onBackPressed] when [KeyEvent.KEYCODE_BUTTON_B] is lifted + */ + override fun onKeyUp(keyCode : Int, event : KeyEvent?) : Boolean { + if (keyCode == KeyEvent.KEYCODE_BUTTON_B) { + onBackPressed() + return true + } + + return super.onKeyUp(keyCode, event) + } +} diff --git a/app/src/main/java/emu/skyline/input/GuestEvent.kt b/app/src/main/java/emu/skyline/input/GuestEvent.kt new file mode 100644 index 00000000..0bbfa3b6 --- /dev/null +++ b/app/src/main/java/emu/skyline/input/GuestEvent.kt @@ -0,0 +1,156 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.input + +import java.io.Serializable +import java.util.* +import kotlin.math.abs + +/** + * This enumerates all of the buttons that the emulator recognizes + */ +enum class ButtonId(val short : String? = null, val long : Int? = null) { + A("A", emu.skyline.R.string.a_button), + B("B", emu.skyline.R.string.b_button), + X("X", emu.skyline.R.string.x_button), + Y("Y", emu.skyline.R.string.y_button), + LeftStick("L"), + RightStick("R"), + L("L", emu.skyline.R.string.left_shoulder), + R("R", emu.skyline.R.string.right_shoulder), + ZL("ZL", emu.skyline.R.string.left_trigger), + ZR("ZR", emu.skyline.R.string.right_trigger), + Plus("+", emu.skyline.R.string.plus_button), + Minus("-", emu.skyline.R.string.minus_button), + DpadLeft("◀", emu.skyline.R.string.left), + DpadUp("▲", emu.skyline.R.string.up), + DpadRight("▶", emu.skyline.R.string.right), + DpadDown("▼", emu.skyline.R.string.down), + LeftStickLeft, + LeftStickUp, + LeftStickRight, + LeftStickDown, + RightStickLeft, + RightStickUp, + RightStickRight, + RightStickDown, + LeftSL("SL", emu.skyline.R.string.left_shoulder), + LeftSR("SR", emu.skyline.R.string.right_shoulder), + RightSL("SL", emu.skyline.R.string.left_shoulder), + RightSR("SR", emu.skyline.R.string.right_shoulder), + Menu("⌂", emu.skyline.R.string.emu_menu_button); + + /** + * This returns the value as setting the [ordinal]-th bit in a [Long] + */ + fun value() : Long { + return (1.toLong()) shl ordinal + } +} + +/** + * This enumerates the states of a button and denotes their Boolean values in [state] + */ +enum class ButtonState(val state : Boolean) { + Released(false), + Pressed(true), +} + +/** + * This enumerates all of the axes on a controller that the emulator recognizes + */ +enum class AxisId { + RX, + RY, + LX, + LY, +} + +/** + * This enumerates all the sticks on a controller with all their components + * + * @param xAxis The [AxisId] corresponding to movement on the X-axis for the stick + * @param yAxis The [AxisId] corresponding to movement on the Y-axis for the stick + * @param button The [ButtonId] of the button activated when the stick is pressed + */ +enum class StickId(val xAxis : AxisId, val yAxis : AxisId, val button : ButtonId) { + Left(AxisId.LX, AxisId.LY, ButtonId.LeftStick), + Right(AxisId.RX, AxisId.RY, ButtonId.RightStick); + + override fun toString() = "$name Stick" +} + +/** + * This an abstract class for all guest events that is inherited by all other event classes + * + * @param id The ID of the guest controller this event corresponds to + */ +abstract class GuestEvent(val id : Int) : Serializable { + /** + * The equality function is abstract so that equality checking will be for the derived classes rather than this abstract class + */ + abstract override fun equals(other : Any?) : Boolean + + /** + * The hash function is abstract so that hashes will be generated for the derived classes rather than this abstract class + */ + abstract override fun hashCode() : Int +} + +/** + * This class is used for all guest events that correspond to a button + * + * @param button The ID of the button that this represents + * @param threshold The threshold of a corresponding [MotionHostEvent]'s axis value for this to be "pressed" + */ +class ButtonGuestEvent(id : Int, val button : ButtonId, val threshold : Float = 0f) : GuestEvent(id) { + /** + * This does some basic equality checking for the type of [other] and all members in the class except [threshold] as that is irrelevant for a lookup + */ + override fun equals(other : Any?) : Boolean = if (other is ButtonGuestEvent) this.id == other.id && this.button == other.button else false + + /** + * This computes the hash for all members of the class except [threshold] as that is irrelevant for a lookup + */ + override fun hashCode() : Int = Objects.hash(id, button) +} + +/** + * This class is used for all guest events that correspond to a specific pole of an axis + * + * @param axis The ID of the axis that this represents + * @param polarity The polarity of the axis this represents + * @param max The maximum recorded value of the corresponding [MotionHostEvent] to scale the axis value + */ +class AxisGuestEvent(id : Int, val axis : AxisId, val polarity : Boolean, var max : Float = 1f) : GuestEvent(id) { + /** + * This does some basic equality checking for the type of [other] and all members in the class except [max] as that is irrelevant for a lookup + */ + override fun equals(other : Any?) : Boolean = if (other is AxisGuestEvent) this.id == other.id && this.axis == other.axis && this.polarity == other.polarity else false + + /** + * This computes the hash for all members of the class except [max] as that is irrelevant for a lookup + */ + override fun hashCode() : Int = Objects.hash(id, axis, polarity) + + /** + * This is used to retrieve the scaled value/update the maximum value of this axis + * + * @param axis The unscaled value of the axis to scale + * @return The scaled value of this axis + */ + fun value(axis : Float) : Float { + if (max == 1f) return axis + + val axisAbs = abs(axis) + if (axisAbs >= max) { + max = axisAbs + return 1f + } + + return axis + (axis * (1f - max)) + } +} diff --git a/app/src/main/java/emu/skyline/input/HostEvent.kt b/app/src/main/java/emu/skyline/input/HostEvent.kt new file mode 100644 index 00000000..a77d81fd --- /dev/null +++ b/app/src/main/java/emu/skyline/input/HostEvent.kt @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.input + +import android.view.KeyEvent +import android.view.MotionEvent +import java.io.Serializable +import java.util.* + +/** + * This an abstract class for all host events that is inherited by all other event classes + * + * @param descriptor The device descriptor of the device this event originates from + */ +abstract class HostEvent(val descriptor : String = "") : Serializable { + /** + * The [toString] function is abstract so that the derived classes can return a proper string + */ + abstract override fun toString() : String + + /** + * The equality function is abstract so that equality checking will be for the derived classes rather than this abstract class + */ + abstract override fun equals(other : Any?) : Boolean + + /** + * The hash function is abstract so that hashes will be generated for the derived classes rather than this abstract class + */ + abstract override fun hashCode() : Int +} + +class KeyHostEvent(descriptor : String = "", val keyCode : Int) : HostEvent(descriptor) { + /** + * This returns the string representation of [keyCode] + */ + override fun toString() : String = KeyEvent.keyCodeToString(keyCode) + + /** + * This does some basic equality checking for the type of [other] and all members in the class + */ + override fun equals(other : Any?) : Boolean = if (other is KeyHostEvent) this.descriptor == other.descriptor && this.keyCode == other.keyCode else false + + /** + * This computes the hash for all members of the class + */ + override fun hashCode() : Int = Objects.hash(descriptor, keyCode) +} + +class MotionHostEvent(descriptor : String = "", val axis : Int, val polarity : Boolean) : HostEvent(descriptor) { + /** + * This returns the string representation of [axis] combined with [polarity] + */ + override fun toString() : String = MotionEvent.axisToString(axis) + if (polarity) "+" else "-" + + /** + * This does some basic equality checking for the type of [other] and all members in the class + */ + override fun equals(other : Any?) : Boolean { + return if (other is MotionHostEvent) this.descriptor == other.descriptor && this.axis == other.axis && this.polarity == other.polarity else false + } + + /** + * This computes the hash for all members of the class + */ + override fun hashCode() : Int = Objects.hash(descriptor, axis, polarity) +} diff --git a/app/src/main/java/emu/skyline/input/InputManager.kt b/app/src/main/java/emu/skyline/input/InputManager.kt new file mode 100644 index 00000000..aef530e1 --- /dev/null +++ b/app/src/main/java/emu/skyline/input/InputManager.kt @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.input + +import android.content.Context +import android.util.Log +import java.io.* + +/** + * This class is used to manage all transactions with storing/retrieving data in relation to input + */ +class InputManager constructor(val context : Context) { + /** + * The underlying [File] object with the input data + */ + private val file = File("${context.applicationInfo.dataDir}/input.bin") + + /** + * A [HashMap] of all the controllers that contains their metadata + */ + lateinit var controllers : HashMap + + /** + * A [HashMap] between all [HostEvent]s and their corresponding [GuestEvent]s + */ + lateinit var eventMap : HashMap + + init { + var readFile = false + + try { + if (file.exists() && file.length() != 0L) { + syncObjects() + readFile = true + } + } catch (e : Exception) { + Log.e(this.toString(), e.localizedMessage ?: "InputManager cannot read \"${file.absolutePath}\"") + } + + if (!readFile) { + controllers = hashMapOf( + 0 to Controller(0, ControllerType.None), + 1 to Controller(1, ControllerType.None), + 2 to Controller(2, ControllerType.None), + 3 to Controller(3, ControllerType.None), + 4 to Controller(4, ControllerType.None), + 5 to Controller(5, ControllerType.None), + 6 to Controller(6, ControllerType.None), + 7 to Controller(7, ControllerType.None)) + + eventMap = hashMapOf() + + syncFile() + } + } + + /** + * This function syncs the class with data from [file] + */ + fun syncObjects() { + val fileInput = FileInputStream(file) + val objectInput = ObjectInputStream(fileInput) + + @Suppress("UNCHECKED_CAST") + controllers = objectInput.readObject() as HashMap + + @Suppress("UNCHECKED_CAST") + eventMap = objectInput.readObject() as HashMap + } + + /** + * This function syncs [file] with data from the class and eliminates unused value from the map + */ + fun syncFile() { + val fileOutput = FileOutputStream(file) + val objectOutput = ObjectOutputStream(fileOutput) + + for (controller in controllers.values) { + for (button in ButtonId.values()) { + if (button != ButtonId.Menu && !(controller.type.buttons.contains(button) || controller.type.sticks.any { it.button == button })) { + val guestEvent = ButtonGuestEvent(controller.id, button) + + eventMap.filterValues { it is ButtonGuestEvent && it == guestEvent }.keys.forEach { eventMap.remove(it) } + } + } + + for (stick in StickId.values()) { + if (!controller.type.sticks.contains(stick)) { + for (axis in arrayOf(stick.xAxis, stick.yAxis)) { + for (polarity in booleanArrayOf(true, false)) { + val guestEvent = AxisGuestEvent(controller.id, axis, polarity) + + eventMap.filterValues { it is AxisGuestEvent && it == guestEvent }.keys.forEach { eventMap.remove(it) } + } + } + } + } + } + + objectOutput.writeObject(controllers) + objectOutput.writeObject(eventMap) + + objectOutput.flush() + } +} diff --git a/app/src/main/java/emu/skyline/input/Npad.kt b/app/src/main/java/emu/skyline/input/Npad.kt deleted file mode 100644 index dbed09ed..00000000 --- a/app/src/main/java/emu/skyline/input/Npad.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * SPDX-License-Identifier: MPL-2.0 - * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) - */ - -package emu.skyline.input - -/** - * This enumerates all buttons on an NPad controller - */ -enum class NpadButton : ButtonId { - A, - B, - X, - Y, - LeftStick, - RightStick, - L, - R, - ZL, - ZR, - Plus, - Minus, - DpadLeft, - DpadUp, - DpadRight, - DpadDown, - LeftStickLeft, - LeftStickUp, - LeftStickRight, - LeftStickDown, - RightStickLeft, - RightStickUp, - RightStickRight, - RightStickDown, - LeftSL, - LeftSR, - RightSL, - RightSR; - - /** - * This just returns the value as setting the [ordinal]-th bit in a [Long] - */ - override fun value() : Long { - return (1.toLong()) shl ordinal - } -} - -/** - * This enumerates all the axis on an NPad controller - */ -enum class NpadAxis { - RX, - RY, - LX, - LY, -} diff --git a/app/src/main/java/emu/skyline/input/dialog/ButtonDialog.kt b/app/src/main/java/emu/skyline/input/dialog/ButtonDialog.kt new file mode 100644 index 00000000..f08de67e --- /dev/null +++ b/app/src/main/java/emu/skyline/input/dialog/ButtonDialog.kt @@ -0,0 +1,240 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.input.dialog + +import android.animation.LayoutTransition +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.* +import android.view.animation.LinearInterpolator +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import emu.skyline.R +import emu.skyline.adapter.ControllerButtonItem +import emu.skyline.input.* +import kotlinx.android.synthetic.main.button_dialog.* +import kotlin.math.abs + +/** + * This dialog is used to set a device to map any buttons + * + * @param item This is used to hold the [ControllerButtonItem] between instances + */ +class ButtonDialog(val item : ControllerButtonItem) : BottomSheetDialogFragment() { + /** + * This inflates the layout of the dialog after initial view creation + */ + override fun onCreateView(inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) : View? = inflater.inflate(R.layout.button_dialog, container) + + /** + * This expands the bottom sheet so that it's fully visible + */ + override fun onStart() { + super.onStart() + + val behavior = BottomSheetBehavior.from(requireView().parent as View) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + /** + * This sets up all user interaction with this dialog + */ + override fun onActivityCreated(savedInstanceState : Bundle?) { + super.onActivityCreated(savedInstanceState) + + if (context is ControllerActivity) { + val context = requireContext() as ControllerActivity + val controller = context.manager.controllers[context.id]!! + + // View focus handling so all input is always directed to this view + view?.requestFocus() + view?.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } + + // Write the text for the button's icon + button_text.text = item.button.short ?: item.button.toString() + + // Set up the reset button to clear out all entries corresponding to this button from [InputManager.eventMap] + button_reset.setOnClickListener { + val guestEvent = ButtonGuestEvent(context.id, item.button) + + context.manager.eventMap.filterValues { it is ButtonGuestEvent && it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) } + + item.update() + + dismiss() + } + + // Ensure that layout animations are proper + button_layout.layoutTransition.setAnimateParentHierarchy(false) + button_layout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING) + + // We want the secondary progress bar to be visible through the first one + button_seekbar.progressDrawable.alpha = 128 + + var deviceId : Int? = null // The ID of the currently selected device + var inputId : Int? = null // The key code/axis ID of the currently selected event + + var axisPolarity = false // The polarity of the axis for the currently selected event + var axisRunnable : Runnable? = null // The Runnable that is used for counting down till an axis is selected + val axisHandler = Handler(Looper.getMainLooper()) // The handler responsible for handling posting [axisRunnable] + + // The last values of the HAT axes so that they can be ignored in [View.OnGenericMotionListener] so they are passed onto [DialogInterface.OnKeyListener] as [KeyEvent]s + var oldDpadX = 0.0f + var oldDpadY = 0.0f + + dialog?.setOnKeyListener { _, _, event -> + // We want all input events from Joysticks and Buttons except for [KeyEvent.KEYCODE_BACK] as that will should be processed elsewhere + if (((event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON) && event.keyCode != KeyEvent.KEYCODE_BACK) || event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK)) && event.repeatCount == 0) { + if ((deviceId != event.deviceId || inputId != event.keyCode) && event.action == KeyEvent.ACTION_DOWN) { + // We set [deviceId] and [inputId] on [KeyEvent.ACTION_DOWN] alongside updating the views to match the action + deviceId = event.deviceId + inputId = event.keyCode + + if (axisRunnable != null) { + axisHandler.removeCallbacks(axisRunnable!!) + axisRunnable = null + } + + button_icon.animate().alpha(0.75f).setDuration(50).start() + button_text.animate().alpha(0.9f).setDuration(50).start() + + button_title.text = getString(R.string.release_confirm) + button_seekbar.visibility = View.GONE + } else if (deviceId == event.deviceId && inputId == event.keyCode && event.action == KeyEvent.ACTION_UP) { + // We serialize the current [deviceId] and [inputId] into a [KeyHostEvent] and map it to a corresponding [GuestEvent] on [KeyEvent.ACTION_UP] + val hostEvent = KeyHostEvent(event.device.descriptor, event.keyCode) + + var guestEvent = context.manager.eventMap[hostEvent] + + if (guestEvent is GuestEvent) { + context.manager.eventMap.remove(hostEvent) + + if (guestEvent is ButtonGuestEvent) + context.buttonMap[guestEvent.button]?.update() + else if (guestEvent is AxisGuestEvent) + context.axisMap[guestEvent.axis]?.update() + } + + guestEvent = ButtonGuestEvent(context.id, item.button) + + context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) } + + context.manager.eventMap[hostEvent] = guestEvent + + item.update() + + dismiss() + } + + true + } else { + false + } + } + + val axes = arrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_THROTTLE, MotionEvent.AXIS_RUDDER, MotionEvent.AXIS_WHEEL, MotionEvent.AXIS_GAS, MotionEvent.AXIS_BRAKE).plus(IntRange(MotionEvent.AXIS_GENERIC_1, MotionEvent.AXIS_GENERIC_16).toList()) + + val axesHistory = arrayOfNulls(axes.size) // The last recorded value of an axis, this is used to eliminate any stagnant axes + + view?.setOnGenericMotionListener { _, event -> + // We retrieve the value of the HAT axes so that we can check for change and ignore any input from them so it'll be passed onto the [KeyEvent] handler + val dpadX = event.getAxisValue(MotionEvent.AXIS_HAT_X) + val dpadY = event.getAxisValue(MotionEvent.AXIS_HAT_Y) + + // We want all input events from Joysticks and Buttons that are [MotionEvent.ACTION_MOVE] and not from the D-pad + if ((event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK) || event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON)) && event.action == MotionEvent.ACTION_MOVE && dpadX == oldDpadX && dpadY == oldDpadY) { + // We iterate over every axis to check if any of them pass the selection threshold and if they do then select them by setting [deviceId], [inputId] and [axisPolarity] + for (axisItem in axes.withIndex()) { + val axis = axisItem.value + val value = event.getAxisValue(axis) + + // This checks the history of the axis so it we can ignore any stagnant axis + if ((event.historySize == 0 || value == event.getHistoricalAxisValue(axis, 0)) && (axesHistory[axisItem.index]?.let { it == value } != false)) { + axesHistory[axisItem.index] = value + continue + } + + axesHistory[axisItem.index] = value + + if (abs(value) >= 0.5 && (deviceId != event.deviceId || inputId != axis || axisPolarity != (value >= 0)) && !(axes.contains(inputId) && value == event.getAxisValue(inputId!!))) { + deviceId = event.deviceId + inputId = axis + axisPolarity = value >= 0 + + button_title.text = getString(R.string.hold_confirm) + button_seekbar.visibility = View.VISIBLE + + break + } + } + + // If the currently active input is a valid axis + if (axes.contains(inputId)) { + val value = event.getAxisValue(inputId!!) + val threshold = button_seekbar.progress / 100f + + // Update the secondary progress bar in [button_seekbar] based on the axis's value + button_seekbar.secondaryProgress = (abs(value) * 100).toInt() + + // If the axis value crosses the threshold then post [axisRunnable] with a delay and animate the views accordingly + if (abs(value) >= threshold) { + if (axisRunnable == null) { + axisRunnable = Runnable { + val hostEvent = MotionHostEvent(event.device.descriptor, inputId!!, axisPolarity) + + var guestEvent = context.manager.eventMap[hostEvent] + + if (guestEvent is GuestEvent) { + context.manager.eventMap.remove(hostEvent) + + if (guestEvent is ButtonGuestEvent) + context.buttonMap[(guestEvent as ButtonGuestEvent).button]?.update() + else if (guestEvent is AxisGuestEvent) + context.axisMap[(guestEvent as AxisGuestEvent).axis]?.update() + } + + guestEvent = ButtonGuestEvent(controller.id, item.button) + + context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) } + + context.manager.eventMap[hostEvent] = guestEvent + + item.update() + + dismiss() + } + + axisHandler.postDelayed(axisRunnable!!, 1000) + } + + button_icon.animate().alpha(0.85f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start() + button_text.animate().alpha(0.95f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start() + } else { + // If the axis value is below the threshold, remove [axisRunnable] from it being posted and animate the views accordingly + if (axisRunnable != null) { + axisHandler.removeCallbacks(axisRunnable!!) + axisRunnable = null + } + + button_icon.animate().alpha(0.25f).setDuration(50).start() + button_text.animate().alpha(0.35f).setDuration(50).start() + } + } + + true + } else { + oldDpadX = dpadX + oldDpadY = dpadY + + false + } + } + } else { + dismiss() + } + } +} diff --git a/app/src/main/java/emu/skyline/input/dialog/RumbleDialog.kt b/app/src/main/java/emu/skyline/input/dialog/RumbleDialog.kt new file mode 100644 index 00000000..020d5236 --- /dev/null +++ b/app/src/main/java/emu/skyline/input/dialog/RumbleDialog.kt @@ -0,0 +1,123 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.input.dialog + +import android.animation.LayoutTransition +import android.os.Bundle +import android.os.VibrationEffect +import android.view.* +import android.view.animation.LinearInterpolator +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import emu.skyline.R +import emu.skyline.adapter.ControllerGeneralItem +import emu.skyline.input.ControllerActivity +import kotlinx.android.synthetic.main.rumble_dialog.* + +/** + * This dialog is used to set a device to pass on any rumble/force feedback data onto + * + * @param item This is used to hold the [ControllerGeneralItem] between instances + */ +class RumbleDialog(val item : ControllerGeneralItem) : BottomSheetDialogFragment() { + /** + * This inflates the layout of the dialog after initial view creation + */ + override fun onCreateView(inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) : View? = inflater.inflate(R.layout.rumble_dialog, container) + + /** + * This expands the bottom sheet so that it's fully visible + */ + override fun onStart() { + super.onStart() + + val behavior = BottomSheetBehavior.from(requireView().parent as View) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + /** + * This sets up all user interaction with this dialog + */ + override fun onActivityCreated(savedInstanceState : Bundle?) { + super.onActivityCreated(savedInstanceState) + + if (context is ControllerActivity) { + val context = requireContext() as ControllerActivity + val controller = context.manager.controllers[context.id]!! + + // Set up the reset button to clear out [Controller.rumbleDevice] when pressed + rumble_reset.setOnClickListener { + controller.rumbleDevice = null + item.update() + + dismiss() + } + + // Ensure that layout animations are proper + rumble_layout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING) + rumble_controller.layoutTransition.enableTransitionType(LayoutTransition.CHANGING) + + var deviceId : Int? = null // The ID of the currently selected device + + dialog?.setOnKeyListener { _, _, event -> + // We want all input events from Joysticks and game pads + if (event.isFromSource(InputDevice.SOURCE_GAMEPAD) || event.isFromSource(InputDevice.SOURCE_JOYSTICK)) { + if (event.repeatCount == 0 && event.action == KeyEvent.ACTION_DOWN) { + val vibrator = event.device.vibrator + + when { + // If the device doesn't match the currently selected device then update the UI accordingly and set [deviceId] to the current device + deviceId != event.deviceId -> { + rumble_controller_name.text = event.device.name + + if (vibrator.hasVibrator()) { + rumble_controller_supported.text = getString(R.string.supported) + rumble_title.text = getString(R.string.confirm_button_again) + + vibrator.vibrate(VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + rumble_controller_supported.text = getString(R.string.not_supported) + rumble_title.text = getString(R.string.press_any_button) + } + + rumble_controller_icon.animate().apply { + interpolator = LinearInterpolator() + duration = 100 + alpha(if (vibrator.hasVibrator()) 0.75f else 0.5f) + start() + } + + deviceId = event.deviceId + } + + // If the currently selected device has a vibrator then go ahead and select it + vibrator.hasVibrator() -> { + vibrator.vibrate(VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)) + + controller.rumbleDevice = Pair(event.device.descriptor, event.device.name) + + item.update() + + dismiss() + } + + // If the currently selected device doesn't have a vibrator then dismiss the dialog entirely + else -> { + dismiss() + } + } + } + + true + } else { + false + } + } + } else { + dismiss() + } + } +} diff --git a/app/src/main/java/emu/skyline/input/dialog/StickDialog.kt b/app/src/main/java/emu/skyline/input/dialog/StickDialog.kt new file mode 100644 index 00000000..4757c9a2 --- /dev/null +++ b/app/src/main/java/emu/skyline/input/dialog/StickDialog.kt @@ -0,0 +1,605 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.input.dialog + +import android.animation.LayoutTransition +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.TypedValue +import android.view.* +import android.view.animation.LinearInterpolator +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import emu.skyline.R +import emu.skyline.adapter.ControllerStickItem +import emu.skyline.input.* +import kotlinx.android.synthetic.main.stick_dialog.* +import java.util.* +import kotlin.math.abs +import kotlin.math.max + +/** + * This dialog is used to set a device to map any sticks + * + * @param item This is used to hold the [ControllerStickItem] between instances + */ +class StickDialog(val item : ControllerStickItem) : BottomSheetDialogFragment() { + /** + * This enumerates all of the stages this dialog can be in + */ + private enum class DialogStage(val string : Int) { + Button(R.string.stick_button), + YPlus(R.string.y_plus), + YMinus(R.string.y_minus), + XMinus(R.string.x_minus), + XPlus(R.string.x_plus), + Stick(R.string.stick_preview); + } + + /** + * This is the current stage of the dialog + */ + private var stage = DialogStage.Button + + /** + * This is the handler of all [Runnable]s posted by the dialog + */ + private val handler = Handler(Looper.getMainLooper()) + + /** + * This is the [Runnable] that is used for running the current stage's animation + */ + private var stageAnimation : Runnable? = null + + /** + * This is a flag that causes any running animation to immediately halt + */ + private var animationStop = false + + /** + * This inflates the layout of the dialog after initial view creation + */ + override fun onCreateView(inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) : View? { + return requireActivity().layoutInflater.inflate(R.layout.stick_dialog, container) + } + + /** + * This expands the bottom sheet so that it's fully visible + */ + override fun onStart() { + super.onStart() + + val behavior = BottomSheetBehavior.from(requireView().parent as View) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + /** + * This function converts [dip] (Density Independent Pixels) to normal pixels + */ + private fun dipToPixels(dip : Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dip, resources.displayMetrics) + + /** + * This function updates the animation based on the current stage and stops the currently running animation if it hasn't already + */ + @Suppress("LABEL_NAME_CLASH") + fun updateAnimation() { + animationStop = false + stageAnimation?.let { handler.removeCallbacks(it) } + + stick_container?.animate()?.scaleX(1f)?.scaleY(1f)?.alpha(1f)?.translationY(0f)?.translationX(0f)?.rotationX(0f)?.rotationY(0f)?.start() + + when (stage) { + DialogStage.Button -> { + stageAnimation = Runnable { + if (stage != DialogStage.Button || animationStop) + return@Runnable + + stick_container?.animate()?.scaleX(0.85f)?.scaleY(0.85f)?.alpha(1f)?.withEndAction { + if (stage != DialogStage.Button || animationStop) + return@withEndAction + + val runnable = Runnable { + if (stage != DialogStage.Button || animationStop) + return@Runnable + + stick_container?.animate()?.scaleX(1f)?.scaleY(1f)?.alpha(0.85f)?.withEndAction { + if (stage != DialogStage.Button || animationStop) + return@withEndAction + + stageAnimation?.let { + handler.postDelayed(it, 750) + } + }?.start() + } + + handler.postDelayed(runnable, 300) + }?.start() + } + } + + DialogStage.YPlus, DialogStage.YMinus -> { + val polarity = if (stage == DialogStage.YMinus) 1 else -1 + + stageAnimation = Runnable { + if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop) + return@Runnable + + stick_container?.animate()?.setDuration(300)?.translationY(dipToPixels(15f) * polarity)?.rotationX(27f * polarity)?.alpha(1f)?.withEndAction { + if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop) + return@withEndAction + + val runnable = Runnable { + if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop) + return@Runnable + + stick_container?.animate()?.setDuration(250)?.translationY(0f)?.rotationX(0f)?.alpha(0.85f)?.withEndAction { + if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop) + return@withEndAction + + stageAnimation?.let { + handler.postDelayed(it, 750) + } + }?.start() + } + + handler.postDelayed(runnable, 300) + }?.start() + } + } + + DialogStage.XPlus, DialogStage.XMinus -> { + val polarity = if (stage == DialogStage.XPlus) 1 else -1 + + stageAnimation = Runnable { + if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop) + return@Runnable + + stick_container?.animate()?.setDuration(300)?.translationX(dipToPixels(16.5f) * polarity)?.rotationY(27f * polarity)?.alpha(1f)?.withEndAction { + if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop) + return@withEndAction + + val runnable = Runnable { + if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop) + return@Runnable + + stick_container?.animate()?.setDuration(250)?.translationX(0f)?.rotationY(0f)?.alpha(0.85f)?.withEndAction { + if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop) + return@withEndAction + + stageAnimation?.let { + handler.postDelayed(it, 750) + } + }?.start() + } + + handler.postDelayed(runnable, 300) + }?.start() + } + } + + else -> { + } + } + + stageAnimation?.let { handler.postDelayed(it, 750) } + } + + /** + * This function goes to a particular stage based on the offset from the current stage + */ + private fun gotoStage(offset : Int = 1) { + val ordinal = stage.ordinal + offset + val size = DialogStage.values().size + + if (ordinal in 0 until size) { + stage = DialogStage.values()[ordinal] + + stick_title.text = getString(stage.string) + stick_subtitle.text = if (stage != DialogStage.Stick) getString(R.string.use_button_axis) else getString(R.string.use_non_stick) + stick_icon.animate().alpha(0.25f).setDuration(50).start() + stick_name.animate().alpha(0.35f).setDuration(50).start() + stick_seekbar.visibility = View.GONE + + stick_next.text = if (ordinal + 1 == size) getString(R.string.done) else getString(R.string.next) + + updateAnimation() + } else { + dismiss() + } + } + + /** + * This sets up all user interaction with this dialog + */ + override fun onActivityCreated(savedInstanceState : Bundle?) { + super.onActivityCreated(savedInstanceState) + + if (context is ControllerActivity) { + val context = requireContext() as ControllerActivity + val controller = context.manager.controllers[context.id]!! + + // View focus handling so all input is always directed to this view + view?.requestFocus() + view?.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } + + // Write the text for the stick's icon + stick_name.text = item.stick.button.short ?: item.stick.button.toString() + + // Set up the reset button to clear out all entries corresponding to this stick from [InputManager.eventMap] + stick_reset.setOnClickListener { + for (axis in arrayOf(item.stick.xAxis, item.stick.yAxis)) { + for (polarity in booleanArrayOf(true, false)) { + val guestEvent = AxisGuestEvent(context.id, axis, polarity) + + context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) } + } + } + + val guestEvent = ButtonGuestEvent(context.id, item.stick.button) + + context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) } + + item.update() + + dismiss() + } + + // Ensure that layout animations are proper + stick_layout.layoutTransition.setAnimateParentHierarchy(false) + stick_layout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING) + + // We want the secondary progress bar to be visible through the first one + stick_seekbar.progressDrawable.alpha = 128 + + updateAnimation() + + var deviceId : Int? = null // The ID of the currently selected device + var inputId : Int? = null // The key code/axis ID of the currently selected event + + val ignoredEvents = mutableListOf() // The hashes of events that are to be ignored due to being already mapped to some component of the stick + + var axisPolarity = false // The polarity of the axis for the currently selected event + var axisRunnable : Runnable? = null // The Runnable that is used for counting down till an axis is selected + + // The last values of the HAT axes so that they can be ignored in [View.OnGenericMotionListener] so they are passed onto [DialogInterface.OnKeyListener] as [KeyEvent]s + var oldDpadX = 0.0f + var oldDpadY = 0.0f + + stick_next.setOnClickListener { + gotoStage(1) + + deviceId = null + inputId = null + + axisRunnable?.let { handler.removeCallbacks(it) } + } + + view?.setOnKeyListener { _, _, event -> + when { + // We want all input events from Joysticks and Buttons except for [KeyEvent.KEYCODE_BACK] as that will should be processed elsewhere + ((event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON) && event.keyCode != KeyEvent.KEYCODE_BACK) || event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK)) && event.repeatCount == 0 -> { + if (stage == DialogStage.Stick) { + // When the stick is being previewed after everything is mapped we do a lookup into [InputManager.eventMap] to find a corresponding [GuestEvent] and animate the stick correspondingly + when (val guestEvent = context.manager.eventMap[KeyHostEvent(event.device.descriptor, event.keyCode)]) { + is ButtonGuestEvent -> { + if (guestEvent.button == item.stick.button) { + if (event.action == KeyEvent.ACTION_DOWN) { + stick_container?.animate()?.setStartDelay(0)?.setDuration(50)?.scaleX(0.85f)?.scaleY(0.85f)?.start() + + stick_icon.animate().alpha(0.85f).setDuration(50).start() + stick_name.animate().alpha(0.95f).setDuration(50).start() + } else { + stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(1f)?.scaleY(1f)?.start() + + stick_icon.animate().alpha(0.25f).setDuration(25).start() + stick_name.animate().alpha(0.35f).setDuration(25).start() + } + } else if (event.action == KeyEvent.ACTION_UP) { + stick_next?.callOnClick() + } + } + + is AxisGuestEvent -> { + val coefficient = if (event.action == KeyEvent.ACTION_DOWN) { + if (guestEvent.polarity) 1 else -1 + } else { + 0 + } + + if (guestEvent.axis == item.stick.xAxis) { + stick_container?.translationX = dipToPixels(16.5f) * coefficient + stick_container?.rotationY = 27f * coefficient + } else if (guestEvent.axis == item.stick.yAxis) { + stick_container?.translationY = dipToPixels(16.5f) * -coefficient + stick_container?.rotationX = 27f * coefficient + } + } + + null -> if (event.action == KeyEvent.ACTION_UP) stick_next?.callOnClick() + } + } else if (stage != DialogStage.Stick) { + if ((deviceId != event.deviceId || inputId != event.keyCode) && event.action == KeyEvent.ACTION_DOWN && !ignoredEvents.any { it == Objects.hash(event.deviceId, event.keyCode) }) { + // We set [deviceId] and [inputId] on [KeyEvent.ACTION_DOWN] alongside updating the views to match the action while ignoring any events in [ignoredEvents] + deviceId = event.deviceId + inputId = event.keyCode + + if (axisRunnable != null) { + handler.removeCallbacks(axisRunnable!!) + axisRunnable = null + } + + animationStop = true + + val coefficient = if (stage == DialogStage.YMinus || stage == DialogStage.XPlus) 1 else -1 + + when (stage) { + DialogStage.Button -> stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(0.85f)?.scaleY(0.85f)?.alpha(1f)?.start() + DialogStage.YPlus, DialogStage.YMinus -> stick_container?.animate()?.setStartDelay(0)?.setDuration(75)?.translationY(dipToPixels(16.5f) * coefficient)?.rotationX(27f * coefficient)?.alpha(1f)?.start() + DialogStage.XPlus, DialogStage.XMinus -> stick_container?.animate()?.setStartDelay(0)?.setDuration(75)?.translationX(dipToPixels(16.5f) * coefficient)?.rotationY(27f * coefficient)?.alpha(1f)?.start() + else -> { + } + } + + stick_icon.animate().alpha(0.85f).setDuration(50).start() + stick_name.animate().alpha(0.95f).setDuration(50).start() + + stick_subtitle.text = getString(R.string.release_confirm) + stick_seekbar.visibility = View.GONE + } else if (deviceId == event.deviceId && inputId == event.keyCode && event.action == KeyEvent.ACTION_UP) { + // We serialize the current [deviceId] and [inputId] into a [KeyHostEvent] and map it to a corresponding [GuestEvent] and add it to [ignoredEvents] on [KeyEvent.ACTION_UP] + val hostEvent = KeyHostEvent(event.device.descriptor, event.keyCode) + + var guestEvent = context.manager.eventMap[hostEvent] + + if (guestEvent is GuestEvent) { + context.manager.eventMap.remove(hostEvent) + + if (guestEvent is ButtonGuestEvent) + context.buttonMap[guestEvent.button]?.update() + else if (guestEvent is AxisGuestEvent) + context.axisMap[guestEvent.axis]?.update() + } + + guestEvent = when (stage) { + DialogStage.Button -> ButtonGuestEvent(controller.id, item.stick.button) + DialogStage.YPlus, DialogStage.YMinus -> AxisGuestEvent(controller.id, item.stick.yAxis, stage == DialogStage.YPlus) + DialogStage.XPlus, DialogStage.XMinus -> AxisGuestEvent(controller.id, item.stick.xAxis, stage == DialogStage.XPlus) + else -> null + } + + context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) } + + context.manager.eventMap[hostEvent] = guestEvent + + ignoredEvents.add(Objects.hash(deviceId!!, inputId!!)) + + item.update() + + stick_next?.callOnClick() + } + } + + true + } + + event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP -> { + // We handle [KeyEvent.KEYCODE_BACK] by trying to go to the last stage using [gotoStage] + gotoStage(-1) + + deviceId = null + inputId = null + + true + } + + else -> { + false + } + } + } + + val axes = arrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_THROTTLE, MotionEvent.AXIS_RUDDER, MotionEvent.AXIS_WHEEL, MotionEvent.AXIS_GAS, MotionEvent.AXIS_BRAKE).plus(IntRange(MotionEvent.AXIS_GENERIC_1, MotionEvent.AXIS_GENERIC_16).toList()) + + val axesHistory = arrayOfNulls(axes.size) // The last recorded value of an axis, this is used to eliminate any stagnant axes + val axesMax = Array(axes.size) { 0f } // The maximum recorded value of the axis, this is to scale the axis to a stick accordingly (The value is also checked at runtime, so it's fine if this isn't the true maximum) + + view?.setOnGenericMotionListener { _, event -> + // We retrieve the value of the HAT axes so that we can check for change and ignore any input from them so it'll be passed onto the [KeyEvent] handler + val dpadX = event.getAxisValue(MotionEvent.AXIS_HAT_X) + val dpadY = event.getAxisValue(MotionEvent.AXIS_HAT_Y) + + // We want all input events from Joysticks and Buttons that are [MotionEvent.ACTION_MOVE] and not from the D-pad + if ((event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK) || event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON)) && event.action == MotionEvent.ACTION_MOVE && dpadX == oldDpadX && dpadY == oldDpadY) { + if (stage == DialogStage.Stick) { + // When the stick is being previewed after everything is mapped we do a lookup into [InputManager.eventMap] to find a corresponding [GuestEvent] and animate the stick correspondingly + for (axisItem in axes.withIndex()) { + val axis = axisItem.value + var value = event.getAxisValue(axis) + + if ((event.historySize == 0 || value == event.getHistoricalAxisValue(axis, 0)) && (axesHistory[axisItem.index]?.let { it == value } != false)) { + axesHistory[axisItem.index] = value + continue + } + + axesHistory[axisItem.index] = value + + var polarity = value >= 0 + val guestEvent = context.manager.eventMap[MotionHostEvent(event.device.descriptor, axis, polarity)] ?: if (value == 0f) { + polarity = false + context.manager.eventMap[MotionHostEvent(event.device.descriptor, axis, polarity)] + } else { + null + } + + when (guestEvent) { + is ButtonGuestEvent -> { + if (guestEvent.button == item.stick.button) { + if (abs(value) >= guestEvent.threshold) { + stick_container?.animate()?.setStartDelay(0)?.setDuration(50)?.scaleX(0.85f)?.scaleY(0.85f)?.start() + stick_icon.animate().alpha(0.85f).setDuration(50).start() + stick_name.animate().alpha(0.95f).setDuration(50).start() + } else { + stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(1f)?.scaleY(1f)?.start() + stick_icon.animate().alpha(0.25f).setDuration(25).start() + stick_name.animate().alpha(0.35f).setDuration(25).start() + } + } + } + + is AxisGuestEvent -> { + value = guestEvent.value(value) + + val coefficient = if (polarity) abs(value) else -abs(value) + + if (guestEvent.axis == item.stick.xAxis) { + stick_container?.translationX = dipToPixels(16.5f) * coefficient + stick_container?.rotationY = 27f * coefficient + } else if (guestEvent.axis == item.stick.yAxis) { + stick_container?.translationY = dipToPixels(16.5f) * coefficient + stick_container?.rotationX = 27f * -coefficient + } + } + } + } + } else { + // We iterate over every axis to check if any of them pass the selection threshold and if they do then select them by setting [deviceId], [inputId] and [axisPolarity] + for (axisItem in axes.withIndex()) { + val axis = axisItem.value + val value = event.getAxisValue(axis) + + axesMax[axisItem.index] = max(abs(value), axesMax[axisItem.index]) + + // This checks the history of the axis so it we can ignore any stagnant axis + if ((event.historySize == 0 || value == event.getHistoricalAxisValue(axis, 0)) && (axesHistory[axisItem.index]?.let { it == value } != false)) { + axesHistory[axisItem.index] = value + continue + } + + axesHistory[axisItem.index] = value + + if (abs(value) >= 0.5 && (deviceId != event.deviceId || inputId != axis || axisPolarity != (value >= 0)) && !ignoredEvents.any { it == Objects.hash(event.deviceId, axis, value >= 0) } && !(axes.contains(inputId) && value == event.getAxisValue(inputId!!))) { + deviceId = event.deviceId + inputId = axis + axisPolarity = value >= 0 + + stick_subtitle.text = getString(R.string.hold_confirm) + + if (stage == DialogStage.Button) + stick_seekbar.visibility = View.VISIBLE + + animationStop = true + + break + } + } + + // If the currently active input is a valid axis + if (axes.contains(inputId)) { + val value = event.getAxisValue(inputId!!) + val threshold = if (stage == DialogStage.Button) stick_seekbar.progress / 100f else 0.5f + + when (stage) { + // Update the secondary progress bar in [button_seekbar] based on the axis's value + DialogStage.Button -> { + stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(0.85f)?.scaleY(0.85f)?.alpha(1f)?.start() + stick_seekbar.secondaryProgress = (abs(value) * 100).toInt() + } + + + // Update the the position of the stick in the Y-axis based on the axis's value + DialogStage.YPlus, DialogStage.YMinus -> { + val coefficient = if (stage == DialogStage.YMinus) abs(value) else -abs(value) + + stick_container?.translationY = dipToPixels(16.5f) * coefficient + stick_container?.rotationX = 27f * -coefficient + } + + // Update the the position of the stick in the X-axis based on the axis's value + DialogStage.XPlus, DialogStage.XMinus -> { + val coefficient = if (stage == DialogStage.XPlus) abs(value) else -abs(value) + + stick_container?.translationX = dipToPixels(16.5f) * coefficient + stick_container?.rotationY = 27f * coefficient + } + + else -> { + } + } + + // If the axis value crosses the threshold then post [axisRunnable] with a delay and animate the views accordingly + if (abs(value) >= threshold) { + if (axisRunnable == null) { + axisRunnable = Runnable { + val hostEvent = MotionHostEvent(event.device.descriptor, inputId!!, axisPolarity) + + var guestEvent = context.manager.eventMap[hostEvent] + + if (guestEvent is GuestEvent) { + context.manager.eventMap.remove(hostEvent) + + if (guestEvent is ButtonGuestEvent) + context.buttonMap[(guestEvent as ButtonGuestEvent).button]?.update() + else if (guestEvent is AxisGuestEvent) + context.axisMap[(guestEvent as AxisGuestEvent).axis]?.update() + } + + val max = axesMax[axes.indexOf(inputId!!)] + + guestEvent = when (stage) { + DialogStage.Button -> ButtonGuestEvent(controller.id, item.stick.button, threshold) + DialogStage.YPlus, DialogStage.YMinus -> AxisGuestEvent(controller.id, item.stick.yAxis, stage == DialogStage.YPlus, max) + DialogStage.XPlus, DialogStage.XMinus -> AxisGuestEvent(controller.id, item.stick.xAxis, stage == DialogStage.XPlus, max) + else -> null + } + + context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) } + + context.manager.eventMap[hostEvent] = guestEvent + + ignoredEvents.add(Objects.hash(deviceId!!, inputId!!, axisPolarity)) + + axisRunnable = null + + item.update() + + stick_next?.callOnClick() + } + + handler.postDelayed(axisRunnable!!, 1000) + } + + stick_icon.animate().alpha(0.85f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start() + stick_name.animate().alpha(0.95f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start() + } else { + // If the axis value is below the threshold, remove [axisRunnable] from it being posted and animate the views accordingly + if (axisRunnable != null) { + handler.removeCallbacks(axisRunnable!!) + axisRunnable = null + } + + if (stage == DialogStage.Button) + stick_container?.animate()?.setStartDelay(0)?.setDuration(10)?.scaleX(1f)?.scaleY(1f)?.alpha(1f)?.start() + + stick_icon.animate().alpha(0.25f).setDuration(50).start() + stick_name.animate().alpha(0.35f).setDuration(50).start() + } + } + } + + true + } else { + oldDpadX = dpadX + oldDpadY = dpadY + + false + } + } + } else { + dismiss() + } + } +} diff --git a/app/src/main/java/emu/skyline/preference/ControllerPreference.kt b/app/src/main/java/emu/skyline/preference/ControllerPreference.kt new file mode 100644 index 00000000..caa43762 --- /dev/null +++ b/app/src/main/java/emu/skyline/preference/ControllerPreference.kt @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: MPL-2.0 + * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/) + */ + +package emu.skyline.preference + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.util.AttributeSet +import androidx.preference.Preference +import androidx.preference.Preference.SummaryProvider +import emu.skyline.R +import emu.skyline.SettingsActivity +import emu.skyline.input.ControllerActivity + +/** + * This preference is used to launch [ControllerActivity] using a preference + */ +class ControllerPreference : Preference { + /** + * The index of the controller this preference manages + */ + private var index : Int = -1 + + constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) { + for (i in 0 until attrs!!.attributeCount) { + val attr = attrs.getAttributeName(i) + + if (attr.equals("index", ignoreCase = true)) { + index = attrs.getAttributeValue(i).toInt() + break + } + } + + if (index == -1) + throw IllegalArgumentException() + + if (key == null) + key = "controller_$index" + + title = "${context?.getString(R.string.config_controller)} #${index + 1}" + + if (context is SettingsActivity) + summaryProvider = SummaryProvider { _ -> context.inputManager.controllers[index]?.type?.stringRes?.let { context.getString(it) } } + } + + constructor(context : Context?, attrs : AttributeSet?) : this(context, attrs, R.attr.preferenceStyle) + + constructor(context : Context?) : this(context, null) + + /** + * This launches [ControllerActivity] on click to configure the controller + */ + override fun onClick() { + if (context is SettingsActivity) + (context as SettingsActivity).refreshKey = key + + val intent = Intent(context, ControllerActivity::class.java) + intent.putExtra("index", index) + (context as Activity).startActivityForResult(intent, 0) + } +} diff --git a/app/src/main/java/emu/skyline/preference/FolderPreference.kt b/app/src/main/java/emu/skyline/preference/FolderPreference.kt index c99d9ffa..3cca893f 100644 --- a/app/src/main/java/emu/skyline/preference/FolderPreference.kt +++ b/app/src/main/java/emu/skyline/preference/FolderPreference.kt @@ -11,7 +11,9 @@ import android.content.Intent import android.net.Uri import android.util.AttributeSet import androidx.preference.Preference +import androidx.preference.Preference.SummaryProvider import androidx.preference.R +import emu.skyline.SettingsActivity /** * This preference shows the decoded URI of it's preference and launches [FolderActivity] @@ -23,7 +25,10 @@ class FolderPreference : Preference { private var mDirectory : String? = null constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) { - summaryProvider = SimpleSummaryProvider() + summaryProvider = SummaryProvider { preference -> + preference.onSetInitialValue(null) + Uri.decode(preference.mDirectory) ?: "" + } } constructor(context : Context?, attrs : AttributeSet?) : this(context, attrs, R.attr.preferenceStyle) @@ -34,6 +39,9 @@ class FolderPreference : Preference { * This launches [FolderActivity] on click to change the directory */ override fun onClick() { + if (context is SettingsActivity) + (context as SettingsActivity).refreshKey = key + val intent = Intent(context, FolderActivity::class.java) (context as Activity).startActivityForResult(intent, 0) } @@ -44,16 +52,4 @@ class FolderPreference : Preference { override fun onSetInitialValue(defaultValue : Any?) { mDirectory = getPersistedString(defaultValue as String?) } - - /** - * This [Preference.SummaryProvider] is used to set the summary for URI values - */ - private class SimpleSummaryProvider : SummaryProvider { - /** - * This returns the decoded URI of the directory as the summary - */ - override fun provideSummary(preference : FolderPreference) : CharSequence { - return Uri.decode(preference.mDirectory) ?: "" - } - } } diff --git a/app/src/main/res/drawable-night/ic_controller.xml b/app/src/main/res/drawable-night/ic_controller.xml new file mode 100644 index 00000000..4dcc1fa7 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_controller.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_button.xml b/app/src/main/res/drawable/ic_button.xml new file mode 100644 index 00000000..ba8ea434 --- /dev/null +++ b/app/src/main/res/drawable/ic_button.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_controller.xml b/app/src/main/res/drawable/ic_controller.xml new file mode 100644 index 00000000..1ff4b0ae --- /dev/null +++ b/app/src/main/res/drawable/ic_controller.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stick.xml b/app/src/main/res/drawable/ic_stick.xml new file mode 100644 index 00000000..45db0273 --- /dev/null +++ b/app/src/main/res/drawable/ic_stick.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_stick_circle.xml b/app/src/main/res/drawable/ic_stick_circle.xml new file mode 100644 index 00000000..ba8ea434 --- /dev/null +++ b/app/src/main/res/drawable/ic_stick_circle.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/layout/button_dialog.xml b/app/src/main/res/layout/button_dialog.xml new file mode 100644 index 00000000..b62ac3dc --- /dev/null +++ b/app/src/main/res/layout/button_dialog.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + +