Merge pull request #13674 from JosJuice/android-visualize-input

Android: Show input indicators in controller settings
This commit is contained in:
Jordan Woyak
2025-07-20 17:46:47 -05:00
committed by GitHub
26 changed files with 542 additions and 78 deletions

View File

@ -37,7 +37,8 @@ class GameAdapter : RecyclerView.Adapter<GameViewHolder>(),
* @return The created ViewHolder with references to all the child view's members.
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context))
val inflater = LayoutInflater.from(parent.context)
val binding = CardGameBinding.inflate(inflater, parent, false)
binding.root.apply {
setOnClickListener(this@GameAdapter)
setOnLongClickListener(this@GameAdapter)

View File

@ -43,17 +43,17 @@ class CheatsAdapter(
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
CheatItem.TYPE_CHEAT -> {
val listItemCheatBinding = ListItemCheatBinding.inflate(inflater)
val listItemCheatBinding = ListItemCheatBinding.inflate(inflater, parent, false)
addViewListeners(listItemCheatBinding.getRoot())
CheatViewHolder(listItemCheatBinding)
}
CheatItem.TYPE_HEADER -> {
val listItemHeaderBinding = ListItemHeaderBinding.inflate(inflater)
val listItemHeaderBinding = ListItemHeaderBinding.inflate(inflater, parent, false)
addViewListeners(listItemHeaderBinding.root)
HeaderViewHolder(listItemHeaderBinding)
}
CheatItem.TYPE_ACTION -> {
val listItemSubmenuBinding = ListItemSubmenuBinding.inflate(inflater)
val listItemSubmenuBinding = ListItemSubmenuBinding.inflate(inflater, parent, false)
addViewListeners(listItemSubmenuBinding.root)
ActionViewHolder(listItemSubmenuBinding)
}

View File

@ -6,6 +6,7 @@ import android.content.Context
import android.hardware.input.InputManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
@ -13,8 +14,11 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.annotation.Keep
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.dolphinemu.dolphinemu.DolphinApplication
import org.dolphinemu.dolphinemu.utils.LooperThread
import java.util.concurrent.atomic.AtomicBoolean
/**
* This class interfaces with the native ControllerInterface,
@ -24,6 +28,16 @@ object ControllerInterface {
private var inputDeviceListener: InputDeviceListener? = null
private lateinit var looperThread: LooperThread
private var inputStateUpdatePending = AtomicBoolean(false)
private val inputStateVersion = MutableLiveData(0)
private val devicesVersion = MutableLiveData(0)
val inputStateChanged: LiveData<Int>
get() = inputStateVersion
val devicesChanged: LiveData<Int>
get() = devicesVersion
/**
* Activities which want to pass on inputs to native code
* should call this in their own dispatchKeyEvent method.
@ -31,7 +45,13 @@ object ControllerInterface {
* @return true if the emulator core seems to be interested in this event.
* false if the event should be passed on to the default dispatchKeyEvent.
*/
external fun dispatchKeyEvent(event: KeyEvent): Boolean
fun dispatchKeyEvent(event: KeyEvent): Boolean {
val result = dispatchKeyEventNative(event)
onInputStateChanged()
return result
}
private external fun dispatchKeyEventNative(event: KeyEvent): Boolean
/**
* Activities which want to pass on inputs to native code
@ -40,7 +60,13 @@ object ControllerInterface {
* @return true if the emulator core seems to be interested in this event.
* false if the event should be passed on to the default dispatchGenericMotionEvent.
*/
external fun dispatchGenericMotionEvent(event: MotionEvent): Boolean
fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
val result = dispatchGenericMotionEventNative(event)
onInputStateChanged()
return result
}
private external fun dispatchGenericMotionEventNative(event: MotionEvent): Boolean
/**
* [DolphinSensorEventListener] calls this for each axis of a received SensorEvent.
@ -48,7 +74,13 @@ object ControllerInterface {
* @return true if the emulator core seems to be interested in this event.
* false if the sensor can be suspended to save battery.
*/
external fun dispatchSensorEvent(
fun dispatchSensorEvent(deviceQualifier: String, axisName: String, value: Float): Boolean {
val result = dispatchSensorEventNative(deviceQualifier, axisName, value)
onInputStateChanged()
return result
}
private external fun dispatchSensorEventNative(
deviceQualifier: String,
axisName: String,
value: Float
@ -76,6 +108,27 @@ object ControllerInterface {
external fun getDevice(deviceString: String): CoreDevice?
private fun onInputStateChanged() {
// When a single SensorEvent is dispatched, this method is likely to get called many times.
// For the sake of performance, let's batch input state updates so that observers only have
// to process one update.
if (!inputStateUpdatePending.getAndSet(true)) {
Handler(Looper.getMainLooper()).post {
if (inputStateUpdatePending.getAndSet(false)) {
inputStateVersion.value = inputStateVersion.value?.plus(1)
}
}
}
}
@Keep
@JvmStatic
private fun onDevicesChanged() {
Handler(Looper.getMainLooper()).post {
devicesVersion.value = devicesVersion.value?.plus(1)
}
}
@Keep
@JvmStatic
private fun registerInputDeviceListener() {

View File

@ -18,6 +18,8 @@ class CoreDevice private constructor(private val pointer: Long) {
@Keep
inner class Control private constructor(private val pointer: Long) {
external fun getName(): String
external fun getState(): Double
}
protected external fun finalize()

View File

@ -18,6 +18,9 @@ class InputMappingControlSetting(var control: Control, val controller: EmulatedC
controller.updateSingleControlReference(controlReference)
}
val state: Double
get() = controlReference.getState()
fun clearValue() {
value = ""
}

View File

@ -4,21 +4,27 @@ package org.dolphinemu.dolphinemu.features.input.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.RecyclerView
import org.dolphinemu.dolphinemu.databinding.ListItemAdvancedMappingControlBinding
import org.dolphinemu.dolphinemu.features.input.model.CoreDevice
import java.util.function.Consumer
class AdvancedMappingControlAdapter(private val onClickCallback: Consumer<String>) :
RecyclerView.Adapter<AdvancedMappingControlViewHolder>() {
private var controls = emptyArray<String>()
class AdvancedMappingControlAdapter(
private val parentLifecycle: Lifecycle,
private val isInput: Boolean,
private val onClickCallback: Consumer<String>
) : RecyclerView.Adapter<AdvancedMappingControlViewHolder>() {
private var controls = emptyArray<CoreDevice.Control>()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): AdvancedMappingControlViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = ListItemAdvancedMappingControlBinding.inflate(inflater)
return AdvancedMappingControlViewHolder(binding, onClickCallback)
val binding = ListItemAdvancedMappingControlBinding.inflate(inflater, parent, false)
return AdvancedMappingControlViewHolder(binding, parentLifecycle, isInput, onClickCallback)
}
override fun onBindViewHolder(holder: AdvancedMappingControlViewHolder, position: Int) =
@ -26,8 +32,23 @@ class AdvancedMappingControlAdapter(private val onClickCallback: Consumer<String
override fun getItemCount(): Int = controls.size
fun setControls(controls: Array<String>) {
fun setControls(controls: Array<CoreDevice.Control>) {
this.controls = controls
notifyDataSetChanged()
}
override fun onViewRecycled(holder: AdvancedMappingControlViewHolder) {
super.onViewRecycled(holder)
holder.onViewRecycled()
}
override fun onViewAttachedToWindow(holder: AdvancedMappingControlViewHolder) {
super.onViewAttachedToWindow(holder)
holder.onViewAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: AdvancedMappingControlViewHolder) {
super.onViewDetachedFromWindow(holder)
holder.onViewDetachedFromWindow()
}
}

View File

@ -2,22 +2,68 @@
package org.dolphinemu.dolphinemu.features.input.ui
import androidx.recyclerview.widget.RecyclerView
import android.view.View
import androidx.lifecycle.Lifecycle
import org.dolphinemu.dolphinemu.databinding.ListItemAdvancedMappingControlBinding
import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
import org.dolphinemu.dolphinemu.features.input.model.CoreDevice
import org.dolphinemu.dolphinemu.utils.LifecycleViewHolder
import java.text.DecimalFormat
import java.util.function.Consumer
import kotlin.math.abs
class AdvancedMappingControlViewHolder(
private val binding: ListItemAdvancedMappingControlBinding,
private val parentLifecycle: Lifecycle,
private val isInput: Boolean,
onClickCallback: Consumer<String>
) : RecyclerView.ViewHolder(binding.root) {
private lateinit var name: String
) : LifecycleViewHolder(binding.root, parentLifecycle) {
private lateinit var control: CoreDevice.Control
private var previousState = Float.POSITIVE_INFINITY
init {
binding.root.setOnClickListener { onClickCallback.accept(name) }
binding.root.setOnClickListener { onClickCallback.accept(control.getName()) }
if (isInput) {
ControllerInterface.inputStateChanged.observe(this) {
updateInputValue()
}
} else {
binding.layoutState.visibility = View.GONE
}
}
fun bind(name: String) {
this.name = name
binding.textName.text = name
fun bind(control: CoreDevice.Control) {
this.control = control
binding.textName.text = control.getName()
if (isInput) {
updateInputValue()
}
}
private fun updateInputValue() {
if (parentLifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("AdvancedMappingControlViewHolder leak")
}
var state = control.getState().toFloat()
if (abs(state - previousState) >= stateUpdateThreshold) {
previousState = state
// Don't print a minus sign for signed zeroes
if (state == -0.0f)
state = 0.0f
binding.textState.setText(stateFormat.format(state))
binding.controlStateBar.state = state
}
}
companion object {
private val stateFormat = DecimalFormat("#.####")
// For performance, require state to change by a certain threshold before we update the UI
private val stateUpdateThreshold = 0.00005f
}
}

View File

@ -3,6 +3,8 @@
package org.dolphinemu.dolphinemu.features.input.ui
import android.content.Context
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.widget.AdapterView
import android.widget.AdapterView.OnItemClickListener
@ -23,7 +25,7 @@ class AdvancedMappingDialog(
private val controlReference: ControlReference,
private val controller: EmulatedController
) : AlertDialog(context), OnItemClickListener {
private val devices: Array<String> = ControllerInterface.getAllDeviceStrings()
private lateinit var devices: Array<String>
private val controlAdapter: AdvancedMappingControlAdapter
private lateinit var selectedDevice: String
@ -34,12 +36,9 @@ class AdvancedMappingDialog(
binding.dropdownDevice.onItemClickListener = this
val deviceAdapter =
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, devices)
binding.dropdownDevice.setAdapter(deviceAdapter)
controlAdapter =
AdvancedMappingControlAdapter { control: String -> onControlClicked(control) }
controlAdapter = AdvancedMappingControlAdapter(lifecycle, controlReference.isInput()) {
control: String -> onControlClicked(control)
}
binding.listControl.adapter = controlAdapter
binding.listControl.layoutManager = LinearLayoutManager(context)
@ -49,6 +48,12 @@ class AdvancedMappingDialog(
binding.editExpression.setText(controlReference.getExpression())
ControllerInterface.devicesChanged.observe(this) {
onDevicesChanged()
setSelectedDevice(selectedDevice)
}
onDevicesChanged()
selectDefaultDevice()
}
@ -59,6 +64,23 @@ class AdvancedMappingDialog(
override fun onItemClick(adapterView: AdapterView<*>?, view: View, position: Int, id: Long) =
setSelectedDevice(devices[position])
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
ControllerInterface.dispatchKeyEvent(event)
return super.dispatchKeyEvent(event)
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
ControllerInterface.dispatchGenericMotionEvent(event)
return super.dispatchGenericMotionEvent(event)
}
private fun onDevicesChanged() {
devices = ControllerInterface.getAllDeviceStrings()
binding.dropdownDevice.setAdapter(
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, devices)
)
}
private fun setSelectedDevice(deviceString: String) {
selectedDevice = deviceString
@ -72,7 +94,7 @@ class AdvancedMappingDialog(
}
private fun setControls(controls: Array<CoreDevice.Control>) =
controlAdapter.setControls(controls.map { it.getName() }.toTypedArray())
controlAdapter.setControls(controls)
private fun onControlClicked(control: String) {
val expression =

View File

@ -0,0 +1,25 @@
package org.dolphinemu.dolphinemu.features.input.ui
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import org.dolphinemu.dolphinemu.databinding.ViewControlStateBarHorizontalBinding
class ControlStateBarHorizontal @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val binding =
ViewControlStateBarHorizontalBinding.inflate(LayoutInflater.from(context), this)
private val impl = ControlStateBarImpl(binding.viewFilled, binding.viewUnfilled)
var state: Float
get() = impl.state
set(value) {
impl.state = value
}
}

View File

@ -0,0 +1,28 @@
package org.dolphinemu.dolphinemu.features.input.ui
import android.view.View
import android.widget.LinearLayout
import androidx.core.math.MathUtils
class ControlStateBarImpl(private val filledView: View, private val unfilledView: View) {
private var _state = 0.0f
var state: Float
get() = _state
set(value) {
val clampedState = MathUtils.clamp(value, 0.0f, 1.0f)
if (clampedState != _state) {
setLinearLayoutWeight(filledView, clampedState)
setLinearLayoutWeight(unfilledView, 1.0f - clampedState)
}
_state = clampedState
}
companion object {
private fun setLinearLayoutWeight(view: View, weight: Float) {
val layoutParams = view.layoutParams as LinearLayout.LayoutParams
layoutParams.weight = weight
view.layoutParams = layoutParams
}
}
}

View File

@ -0,0 +1,30 @@
package org.dolphinemu.dolphinemu.features.input.ui
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import org.dolphinemu.dolphinemu.databinding.ViewControlStateBarVerticalBinding
class ControlStateBarVertical @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
init {
// Setting this in the XML file has no effect for some reason
orientation = VERTICAL
}
private val binding =
ViewControlStateBarVerticalBinding.inflate(LayoutInflater.from(context), this)
private val impl = ControlStateBarImpl(binding.viewFilled, binding.viewUnfilled)
var state: Float
get() = impl.state
set(value) {
impl.state = value
}
}

View File

@ -3,18 +3,30 @@
package org.dolphinemu.dolphinemu.features.input.ui.viewholder
import android.view.View
import androidx.lifecycle.Lifecycle
import org.dolphinemu.dolphinemu.databinding.ListItemMappingBinding
import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
import org.dolphinemu.dolphinemu.features.input.model.view.InputMappingControlSetting
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.features.settings.ui.SettingsAdapter
import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SettingViewHolder
import kotlin.math.abs
class InputMappingControlSettingViewHolder(
private val binding: ListItemMappingBinding,
adapter: SettingsAdapter
) : SettingViewHolder(binding.getRoot(), adapter) {
lateinit var setting: InputMappingControlSetting
private var previousState = Float.POSITIVE_INFINITY
init {
ControllerInterface.inputStateChanged.observe(this) {
updateInputValue()
}
}
override val item: SettingsItem
get() = setting
@ -25,7 +37,12 @@ class InputMappingControlSettingViewHolder(
binding.textSettingDescription.text = setting.value
binding.buttonAdvancedSettings.setOnClickListener { clicked: View -> onLongClick(clicked) }
if (!setting.isInput) {
binding.controlStateBar.visibility = View.INVISIBLE
}
setStyle(binding.textSettingName, setting)
updateInputValue()
}
override fun onClick(clicked: View) {
@ -52,4 +69,23 @@ class InputMappingControlSettingViewHolder(
return true
}
private fun updateInputValue() {
if (adapter.getFragmentLifecycle().currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("InputMappingControlSettingViewHolder leak")
}
if (setting.isInput) {
val state = setting.state.toFloat()
if (abs(state - previousState) >= stateUpdateThreshold) {
previousState = state
binding.controlStateBar.state = state
}
}
}
companion object {
// For performance, require state to change by a certain threshold before we update the UI
private val stateUpdateThreshold = 0.01f
}
}

View File

@ -32,7 +32,7 @@ class RiivolutionAdapter(private val context: Context, private val patches: Riiv
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RiivolutionViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = ListItemRiivolutionBinding.inflate(inflater)
val binding = ListItemRiivolutionBinding.inflate(inflater, parent, false)
return RiivolutionViewHolder(binding.root, binding)
}

View File

@ -11,7 +11,9 @@ import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
import android.view.MotionEvent
import android.view.View
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
@ -22,11 +24,11 @@ import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.NativeLibrary
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.databinding.ActivitySettingsBinding
import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.features.settings.ui.SettingsFragment.Companion.newInstance
import org.dolphinemu.dolphinemu.ui.main.MainPresenter
@ -197,6 +199,16 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView {
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
ControllerInterface.dispatchKeyEvent(event)
return super.dispatchKeyEvent(event)
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
ControllerInterface.dispatchGenericMotionEvent(event)
return super.dispatchGenericMotionEvent(event)
}
private fun canonicalizeIfPossible(uri: Uri): Uri {
val canonicalizedUri = contentResolver.canonicalize(uri)
return canonicalizedUri ?: uri

View File

@ -16,6 +16,7 @@ import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.datepicker.CalendarConstraints
@ -67,57 +68,48 @@ class SettingsAdapter(
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
SettingsItem.TYPE_HEADER -> HeaderViewHolder(
ListItemHeaderBinding.inflate(inflater),
ListItemHeaderBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_SWITCH -> SwitchSettingViewHolder(
ListItemSettingSwitchBinding.inflate(inflater),
ListItemSettingSwitchBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_STRING_SINGLE_CHOICE,
SettingsItem.TYPE_SINGLE_CHOICE_DYNAMIC_DESCRIPTIONS,
SettingsItem.TYPE_SINGLE_CHOICE -> SingleChoiceViewHolder(
ListItemSettingBinding.inflate(inflater),
ListItemSettingBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_SLIDER -> SliderViewHolder(
ListItemSettingBinding.inflate(
inflater
), this, context
ListItemSettingBinding.inflate(inflater, parent, false),
this,
context
)
SettingsItem.TYPE_SUBMENU -> SubmenuViewHolder(
ListItemSubmenuBinding.inflate(
inflater
), this
ListItemSubmenuBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_INPUT_MAPPING_CONTROL -> InputMappingControlSettingViewHolder(
ListItemMappingBinding.inflate(inflater),
ListItemMappingBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_FILE_PICKER -> FilePickerViewHolder(
ListItemSettingBinding.inflate(
inflater
), this
ListItemSettingBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_RUN_RUNNABLE -> RunRunnableViewHolder(
ListItemSettingBinding.inflate(
inflater
), this, context
ListItemSettingBinding.inflate(inflater, parent, false),
this, context
)
SettingsItem.TYPE_STRING -> InputStringSettingViewHolder(
ListItemSettingBinding.inflate(
inflater
), this
ListItemSettingBinding.inflate(inflater, parent, false), this
)
SettingsItem.TYPE_HYPERLINK_HEADER -> HeaderHyperLinkViewHolder(
ListItemHeaderBinding.inflate(
inflater
), this
ListItemHeaderBinding.inflate(inflater, parent, false), this
)
SettingsItem.TYPE_DATETIME_CHOICE -> DateTimeSettingViewHolder(
ListItemSettingBinding.inflate(
inflater
), this
ListItemSettingBinding.inflate(inflater, parent, false), this
)
else -> throw IllegalArgumentException("Invalid view type: $viewType")
}
@ -543,6 +535,25 @@ class SettingsAdapter(
}
}
override fun onViewRecycled(holder: SettingViewHolder) {
super.onViewRecycled(holder)
holder.onViewRecycled()
}
override fun onViewAttachedToWindow(holder: SettingViewHolder) {
super.onViewAttachedToWindow(holder)
holder.onViewAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: SettingViewHolder) {
super.onViewDetachedFromWindow(holder)
holder.onViewDetachedFromWindow()
}
fun getFragmentLifecycle(): Lifecycle {
return fragmentView.getFragmentLifecycle()
}
private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int {
val valuesId = item.valuesId

View File

@ -18,6 +18,7 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
@ -25,7 +26,6 @@ import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.databinding.FragmentSettingsBinding
import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.ui.main.MainActivity
import org.dolphinemu.dolphinemu.ui.main.MainPresenter
import org.dolphinemu.dolphinemu.utils.GpuDriverInstallResult
import org.dolphinemu.dolphinemu.utils.SerializableHelper.serializable
@ -200,6 +200,10 @@ class SettingsFragment : Fragment(), SettingsFragmentView {
.show()
}
override fun getFragmentLifecycle(): Lifecycle {
return lifecycle
}
private fun askForDriverFile() {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

View File

@ -4,6 +4,7 @@ package org.dolphinemu.dolphinemu.features.settings.ui
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.utils.GpuDriverInstallResult
@ -119,4 +120,9 @@ interface SettingsFragmentView {
* Shows a dialog asking the user to install or uninstall a GPU driver
*/
fun showGpuDriverDialog()
/**
* Returns the Lifecycle for the Fragment.
*/
fun getFragmentLifecycle(): Lifecycle
}

View File

@ -9,15 +9,17 @@ import android.view.View
import android.view.View.OnLongClickListener
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.DolphinApplication
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.features.settings.ui.SettingsAdapter
import org.dolphinemu.dolphinemu.utils.LifecycleViewHolder
abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) :
RecyclerView.ViewHolder(itemView), View.OnClickListener, OnLongClickListener {
LifecycleViewHolder(itemView, adapter.getFragmentLifecycle()),
LifecycleOwner, View.OnClickListener, OnLongClickListener {
init {
itemView.setOnClickListener(this)

View File

@ -0,0 +1,59 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils
import android.view.View
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.recyclerview.widget.RecyclerView
/**
* A ViewHolder with an attached Lifecycle.
*
* @param itemView The view held by the ViewHolder.
* @param parentLifecycle If the passed-in Lifecycle changes state to DESTROYED, this ViewHolder
* will also change state to DESTROYED. This should normally be set to the
* Lifecycle of the containing Fragment or Activity to prevent instances
* of this class from leaking.
*/
abstract class LifecycleViewHolder(itemView: View, parentLifecycle: Lifecycle) :
RecyclerView.ViewHolder(itemView), LifecycleOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
override val lifecycle: Lifecycle
get() = lifecycleRegistry
init {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
parentLifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
// Make sure this ViewHolder doesn't leak if its lifecycle has observers
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
})
}
/**
* To be called from a function overriding [RecyclerView.Adapter.onViewRecycled].
*/
fun onViewRecycled() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
}
/**
* To be called from a function overriding [RecyclerView.Adapter.onViewAttachedToWindow].
*/
fun onViewAttachedToWindow() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}
/**
* To be called from a function overriding [RecyclerView.Adapter.onViewDetachedFromWindow].
*/
fun onViewDetachedFromWindow() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
}
}

View File

@ -22,7 +22,7 @@
android:layout_marginTop="@dimen/spacing_large"
android:textSize="16sp"
android:textAlignment="viewStart"
android:layout_toStartOf="@+id/button_advanced_settings"
android:layout_toStartOf="@id/control_state_bar"
tools:text="Setting Name" />
<TextView
@ -30,16 +30,23 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignStart="@+id/text_setting_name"
android:layout_below="@+id/text_setting_name"
android:layout_alignStart="@id/text_setting_name"
android:layout_below="@id/text_setting_name"
android:layout_marginBottom="@dimen/spacing_large"
android:layout_marginEnd="@dimen/spacing_large"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginTop="@dimen/spacing_small"
android:layout_toStartOf="@+id/button_advanced_settings"
android:layout_toStartOf="@id/control_state_bar"
android:textAlignment="viewStart"
tools:text="@string/overclock_enable_description" />
<org.dolphinemu.dolphinemu.features.input.ui.ControlStateBarVertical
android:id="@+id/control_state_bar"
android:layout_width="4dp"
android:layout_height="16dp"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/button_advanced_settings" />
<Button
android:id="@+id/button_advanced_settings"
style="?attr/materialIconButtonStyle"

View File

@ -1,27 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:minHeight="54dp"
android:background="?android:attr/selectableItemBackground"
android:focusable="true"
android:clickable="true">
<TextView
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="2"
style="@style/TextAppearance.MaterialComponents.Headline5"
tools:text="Button A"
android:layout_alignParentEnd="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginEnd="@dimen/spacing_large"
android:layout_marginTop="@dimen/spacing_large"
android:padding="@dimen/spacing_large"
android:id="@+id/text_name"
android:textAlignment="viewStart"
android:textSize="16sp" />
</RelativeLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:id="@+id/layout_state">
<org.dolphinemu.dolphinemu.features.input.ui.ControlStateBarHorizontal
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/control_state_bar" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|center_vertical"
style="@style/TextAppearance.MaterialComponents.Headline5"
tools:text="0.1234"
android:padding="@dimen/spacing_large"
android:id="@+id/text_state"
android:textAlignment="center"
android:textSize="16sp" />
</FrameLayout>
</LinearLayout>

View File

@ -22,7 +22,7 @@
android:layout_marginTop="@dimen/spacing_large"
android:textSize="16sp"
android:textAlignment="viewStart"
android:layout_toStartOf="@+id/button_advanced_settings"
android:layout_toStartOf="@id/control_state_bar"
tools:text="Setting Name" />
<TextView
@ -30,16 +30,23 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignStart="@+id/text_setting_name"
android:layout_below="@+id/text_setting_name"
android:layout_alignStart="@id/text_setting_name"
android:layout_below="@id/text_setting_name"
android:layout_marginBottom="@dimen/spacing_large"
android:layout_marginEnd="@dimen/spacing_large"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginTop="@dimen/spacing_small"
android:layout_toStartOf="@+id/button_advanced_settings"
android:layout_toStartOf="@id/control_state_bar"
android:textAlignment="viewStart"
tools:text="@string/overclock_enable_description" />
<org.dolphinemu.dolphinemu.features.input.ui.ControlStateBarVertical
android:id="@+id/control_state_bar"
android:layout_width="4dp"
android:layout_height="16dp"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/button_advanced_settings" />
<Button
android:id="@+id/button_advanced_settings"
style="?attr/materialIconButtonStyle"

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="LinearLayout">
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="0"
android:id="@+id/view_filled"
android:background="@color/dolphin_errorContainer" />
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:id="@+id/view_unfilled" />
</merge>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="LinearLayout">
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:id="@+id/view_unfilled" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0"
android:id="@+id/view_filled"
android:background="@color/dolphin_error" />
</merge>

View File

@ -64,6 +64,13 @@ Java_org_dolphinemu_dolphinemu_features_input_model_CoreDevice_00024Control_getN
return ToJString(env, GetControlPointer(env, obj)->GetName());
}
JNIEXPORT jdouble JNICALL
Java_org_dolphinemu_dolphinemu_features_input_model_CoreDevice_00024Control_getState(JNIEnv* env,
jobject obj)
{
return env, GetControlPointer(env, obj)->ToInput()->GetState();
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_input_model_CoreDevice_finalize(JNIEnv* env, jobject obj)
{