diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt index 8a1d8addbd..85321f43a7 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt @@ -34,6 +34,7 @@ import com.google.android.material.slider.Slider import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.databinding.ActivityEmulationBinding +import org.dolphinemu.dolphinemu.databinding.DialogOverlayHapticsBinding import org.dolphinemu.dolphinemu.databinding.DialogInputAdjustBinding import org.dolphinemu.dolphinemu.databinding.DialogNfcFiguresManagerBinding import org.dolphinemu.dolphinemu.features.infinitybase.InfinityConfig @@ -42,7 +43,9 @@ import org.dolphinemu.dolphinemu.features.infinitybase.ui.FigureSlot import org.dolphinemu.dolphinemu.features.infinitybase.ui.FigureSlotAdapter import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface import org.dolphinemu.dolphinemu.features.input.model.DolphinSensorEventListener +import org.dolphinemu.dolphinemu.features.input.model.DolphinVibratorManagerFactory import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.FloatSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.Settings import org.dolphinemu.dolphinemu.features.settings.model.StringSetting @@ -56,6 +59,7 @@ import org.dolphinemu.dolphinemu.fragments.EmulationFragment import org.dolphinemu.dolphinemu.fragments.MenuFragment import org.dolphinemu.dolphinemu.fragments.SaveLoadStateFragment import org.dolphinemu.dolphinemu.fragments.SaveLoadStateFragment.SaveOrLoad +import org.dolphinemu.dolphinemu.model.HapticEffect import org.dolphinemu.dolphinemu.overlay.InputOverlay import org.dolphinemu.dolphinemu.overlay.InputOverlayPointer import org.dolphinemu.dolphinemu.ui.main.MainPresenter @@ -63,6 +67,7 @@ import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner import org.dolphinemu.dolphinemu.utils.DirectoryInitialization import org.dolphinemu.dolphinemu.utils.FileBrowserHelper +import org.dolphinemu.dolphinemu.utils.HapticsProvider import org.dolphinemu.dolphinemu.utils.RateLimiter import org.dolphinemu.dolphinemu.utils.ThemeHelper import kotlin.math.roundToInt @@ -443,6 +448,12 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { menu.findItem(R.id.menu_emulation_ir_recenter).isChecked = BooleanSetting.MAIN_IR_ALWAYS_RECENTER.boolean } + // Hide the haptic feedback menu item if the device has no vibrator + if (!DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() + .hasVibrator() + ) { + menu.findItem(R.id.menu_emulation_touch_haptics).isVisible = false + } popup.setOnMenuItemClickListener { item: MenuItem -> onOptionsItemSelected(item) } popup.show() } @@ -518,6 +529,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings() MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings() MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation() + MENU_ACTION_ADJUST_OVERLAY_HAPTICS -> adjustOverlayHaptics() } } @@ -699,6 +711,78 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { .show() } + private fun adjustOverlayHaptics() { + val dialogBinding = DialogOverlayHapticsBinding.inflate(layoutInflater) + val hapticsProvider = HapticsProvider() + dialogBinding.apply { + val checkboxes = listOf( + overlayHapticsOnPressCheckbox, + overlayHapticsOnReleaseCheckbox, + overlayHapticsJoystickCheckbox + ) + val toggleVibrationSettings = { + checkboxes.any { it.isChecked }.let { enabled -> + overlayHapticsUseVibratorDirectlyText.isEnabled = enabled + overlayHapticsUseVibratorDirectlySwitch.isEnabled = enabled + (overlayHapticsUseVibratorDirectlySwitch.isChecked && enabled).let { + overlayHapticsIntensityName.isEnabled = it + overlayHapticsIntensitySlider.isEnabled = it + overlayHapticsIntensityValue.isEnabled = it + } + } + } + overlayHapticsOnPressCheckbox.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean + overlayHapticsOnReleaseCheckbox.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean + overlayHapticsJoystickCheckbox.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean + overlayHapticsUseVibratorDirectlySwitch.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.boolean + toggleVibrationSettings() + checkboxes.forEach { + it.setOnCheckedChangeListener { _, _ -> toggleVibrationSettings() } + } + overlayHapticsUseVibratorDirectlySwitch.setOnCheckedChangeListener { _, _ -> + toggleVibrationSettings() + } + overlayHapticsIntensitySlider.apply { + val setValueText = { value: Float -> + overlayHapticsIntensityValue.text = + getString(R.string.slider_setting_value, value * 100f, '%') + } + stepSize = 0.1f + valueFrom = 0.1f + valueTo = 1.0f + value = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float.also { setValueText(it) } + addOnChangeListener { _: Slider, value: Float, _: Boolean -> + setValueText(value) + hapticsProvider.provideFeedback(HapticEffect.JOYSTICK, null, value) + } + } + } + MaterialAlertDialogBuilder(this) + .setView(dialogBinding.root) + .setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.setBoolean( + settings, dialogBinding.overlayHapticsOnPressCheckbox.isChecked + ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.setBoolean( + settings, dialogBinding.overlayHapticsOnReleaseCheckbox.isChecked + ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.setBoolean( + settings, dialogBinding.overlayHapticsJoystickCheckbox.isChecked + ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.setBoolean( + settings, dialogBinding.overlayHapticsUseVibratorDirectlySwitch.isChecked + ) + FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.setFloat( + settings, dialogBinding.overlayHapticsIntensitySlider.value + ) + } + .show() + } + private fun chooseDoubleTapButton() { val currentValue = IntSetting.MAIN_DOUBLE_TAP_BUTTON.int @@ -1077,6 +1161,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { const val MENU_ACTION_SKYLANDERS = 36 const val MENU_ACTION_INFINITY_BASE = 37 const val MENU_ACTION_LATCHING_CONTROLS = 38 + const val MENU_ACTION_ADJUST_OVERLAY_HAPTICS = 39 init { buttonsActionsMap.apply { @@ -1090,6 +1175,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { append(R.id.menu_emulation_ir_recenter, MENU_SET_IR_RECENTER) append(R.id.menu_emulation_set_ir_mode, MENU_SET_IR_MODE) append(R.id.menu_emulation_choose_doubletap, MENU_ACTION_CHOOSE_DOUBLETAP) + append(R.id.menu_emulation_touch_haptics, MENU_ACTION_ADJUST_OVERLAY_HAPTICS) } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt index 71b37ff513..75a39ba840 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt @@ -10,7 +10,6 @@ import android.os.HandlerThread import android.os.Looper import android.os.VibrationEffect import android.os.Vibrator -import android.os.VibratorManager import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent @@ -154,28 +153,13 @@ object ControllerInterface { @Keep @JvmStatic - private fun getVibratorManager(device: InputDevice): DolphinVibratorManager { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - DolphinVibratorManagerPassthrough(device.vibratorManager) - } else { - DolphinVibratorManagerCompat(device.vibrator) - } - } + private fun getDeviceVibratorManager(device: InputDevice): DolphinVibratorManager = + DolphinVibratorManagerFactory.getDeviceVibratorManager(device) @Keep @JvmStatic - private fun getSystemVibratorManager(): DolphinVibratorManager { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = DolphinApplication.getAppContext() - .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager? - if (vibratorManager != null) { - return DolphinVibratorManagerPassthrough(vibratorManager) - } - } - val vibrator = DolphinApplication.getAppContext() - .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - return DolphinVibratorManagerCompat(vibrator) - } + private fun getSystemVibratorManager(): DolphinVibratorManager = + DolphinVibratorManagerFactory.getSystemVibratorManager() @Keep @JvmStatic diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt index c3c6ba28da..a0e042eaf4 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManager.kt @@ -13,4 +13,6 @@ interface DolphinVibratorManager { fun getVibrator(vibratorId: Int): Vibrator fun getVibratorIds(): IntArray + + fun getDefaultVibrator(): Vibrator } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt index 039f0ecb6b..4c2c6019c7 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerCompat.kt @@ -21,4 +21,6 @@ class DolphinVibratorManagerCompat(vibrator: Vibrator) : DolphinVibratorManager } override fun getVibratorIds(): IntArray = vibratorIds + + override fun getDefaultVibrator(): Vibrator = vibrator } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt new file mode 100644 index 0000000000..a6d17a3292 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.input.model + +import android.content.Context +import android.os.Build +import android.os.Vibrator +import android.os.VibratorManager +import android.view.InputDevice +import org.dolphinemu.dolphinemu.DolphinApplication + +object DolphinVibratorManagerFactory { + fun getDeviceVibratorManager(device: InputDevice): DolphinVibratorManager { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + DolphinVibratorManagerPassthrough(device.vibratorManager) + } else { + DolphinVibratorManagerCompat(device.vibrator) + } + } + + fun getSystemVibratorManager(): DolphinVibratorManager { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = DolphinApplication.getAppContext() + .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager? + if (vibratorManager != null) { + return DolphinVibratorManagerPassthrough(vibratorManager) + } + } + val vibrator = DolphinApplication.getAppContext() + .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + return DolphinVibratorManagerCompat(vibrator) + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt index 0895484314..9a73320119 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerPassthrough.kt @@ -13,4 +13,6 @@ class DolphinVibratorManagerPassthrough(private val vibratorManager: VibratorMan override fun getVibrator(vibratorId: Int): Vibrator = vibratorManager.getVibrator(vibratorId) override fun getVibratorIds(): IntArray = vibratorManager.vibratorIds + + override fun getDefaultVibrator(): Vibrator = vibratorManager.defaultVibrator } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt index cd0ea42d94..2a71f6f375 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt @@ -672,6 +672,30 @@ enum class BooleanSetting( "ButtonLatchingNunchukZ", false ), + MAIN_OVERLAY_HAPTICS_ON_PRESS( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsOnPress", + true + ), + MAIN_OVERLAY_HAPTICS_ON_RELEASE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsOnRelease", + true + ), + MAIN_OVERLAY_HAPTICS_JOYSTICK( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsJoystick", + false + ), + MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsUseVibratorDirectly", + false + ), SYSCONF_SCREENSAVER(Settings.FILE_SYSCONF, "IPL", "SSV", false), SYSCONF_WIDESCREEN(Settings.FILE_SYSCONF, "IPL", "AR", true), SYSCONF_PROGRESSIVE_SCAN(Settings.FILE_SYSCONF, "IPL", "PGS", true), diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt index 67ea46195c..0adf986355 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/FloatSetting.kt @@ -8,6 +8,12 @@ enum class FloatSetting( private val key: String, private val defaultValue: Float ) : AbstractFloatSetting { + MAIN_OVERLAY_HAPTICS_SCALE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsScale", + 0.5f + ), // These entries have the same names and order as in C++, just for consistency. MAIN_EMULATION_SPEED(Settings.FILE_DOLPHIN, Settings.SECTION_INI_CORE, "EmulationSpeed", 1.0f), MAIN_OVERCLOCK(Settings.FILE_DOLPHIN, Settings.SECTION_INI_CORE, "Overclock", 1.0f), diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HapticEffect.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HapticEffect.kt new file mode 100644 index 0000000000..2247e3547d --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HapticEffect.kt @@ -0,0 +1,95 @@ +package org.dolphinemu.dolphinemu.model + +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.annotation.FloatRange +import androidx.annotation.RequiresApi +import androidx.core.view.HapticFeedbackConstantsCompat + +/** + * Enum to represent haptic effects. + * + * @param feedbackConstant One of the constants defined in [HapticFeedbackConstantsCompat]. + * @param maxTimings The maximum timing values, in milliseconds, of the timing / amplitude pairs. + * Note that lower-end vibrators may not be able to vibrate for short (<50ms) durations. + * @param maxAmplitudes The maximum amplitude values of the timing / amplitude pairs. + * Amplitude values must be between 0 and 255. An amplitude value of 0 implies the motor is off. + * @param primaryPrimitive The primary primitive ID, or null if the SDK version is too low. + * @param fallbackPrimitive The fallback primitive ID, or null if the SDK version is too low. + */ +enum class HapticEffect( + val feedbackConstant: Int, + private val maxTimings: LongArray, + private val maxAmplitudes: IntArray, + val primaryPrimitive: Int?, + val fallbackPrimitive: Int? = null +) { + PRESS( + feedbackConstant = HapticFeedbackConstantsCompat.VIRTUAL_KEY, + maxTimings = longArrayOf(0L, 100L), + maxAmplitudes = intArrayOf(0, 180), + primaryPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + VibrationEffect.Composition.PRIMITIVE_QUICK_FALL + } else { + null + }, + fallbackPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + VibrationEffect.Composition.PRIMITIVE_CLICK + } else { + null + } + ), + RELEASE( + feedbackConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + HapticFeedbackConstantsCompat.VIRTUAL_KEY_RELEASE + } else { + HapticFeedbackConstantsCompat.CONTEXT_CLICK // Better than a no-op. + }, + maxTimings = longArrayOf(0L, 70L), + maxAmplitudes = intArrayOf(0, 128), + primaryPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + VibrationEffect.Composition.PRIMITIVE_QUICK_RISE + } else { + null + } + ), + JOYSTICK( + feedbackConstant = HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK, + maxTimings = longArrayOf(50L), + maxAmplitudes = intArrayOf(90), + primaryPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibrationEffect.Composition.PRIMITIVE_LOW_TICK + } else { + null + }, + fallbackPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + VibrationEffect.Composition.PRIMITIVE_TICK + } else { + null + } + ); + + fun getMaxTimings(): LongArray = maxTimings.copyOf() + + @RequiresApi(Build.VERSION_CODES.O) + fun getMaxAmplitudes(): IntArray = maxAmplitudes.copyOf() + + fun scaleTimings(@FloatRange(from = 0.0, to = 1.0) scale: Float): LongArray = + maxTimings.map { (it.toFloat() * scale).toLong() }.toLongArray() + + @RequiresApi(Build.VERSION_CODES.O) + fun scaleAmplitudes(@FloatRange(from = 0.0, to = 1.0) scale: Float): IntArray = + maxAmplitudes.map { (it.toFloat() * scale).toInt() }.toIntArray() + + /** + * Gets this effect's preferred primitive ID that is supported by the provided [vibrator]. + * @param vibrator A [Vibrator] instance for checking whether primitives are supported by it. + * @return The best supported primitive ID of this effect, or null if there is none. + */ + @RequiresApi(Build.VERSION_CODES.R) + fun getSupportedPrimitive(vibrator: Vibrator): Int? = + listOf(primaryPrimitive, fallbackPrimitive).firstOrNull { + it != null && vibrator.areAllPrimitivesSupported(it) + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt index 3d991fed1e..418194cf4d 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt @@ -27,9 +27,12 @@ import org.dolphinemu.dolphinemu.features.input.model.InputOverrider import org.dolphinemu.dolphinemu.features.input.model.InputOverrider.ControlId import org.dolphinemu.dolphinemu.features.input.model.controlleremu.EmulatedController import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.FloatSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting.Companion.getSettingForSIDevice import org.dolphinemu.dolphinemu.features.settings.model.IntSetting.Companion.getSettingForWiimoteSource +import org.dolphinemu.dolphinemu.model.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import java.util.Arrays /** @@ -41,6 +44,7 @@ import java.util.Arrays */ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(context, attrs), OnTouchListener { + private val hapticsProvider: HapticsProvider = HapticsProvider() private val overlayButtons: MutableSet = HashSet() private val overlayDpads: MutableSet = HashSet() private val overlayJoysticks: MutableSet = HashSet() @@ -140,6 +144,11 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN && action != MotionEvent.ACTION_POINTER_UP val pointerIndex = if (firstPointer) 0 else event.actionIndex + val hapticsScale = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float + val hapticsOnPress = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean + val hapticsOnRelease = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean + val hapticsView = + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.boolean) null else v // Tracks if any button/joystick is pressed down var pressed = false @@ -154,7 +163,25 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex event.getY(pointerIndex).toInt() ) ) { - button.setPressedState(if (button.latching) !button.getPressedState() else true) + if (button.latching && button.getPressedState()) { + button.setPressedState(false) + if (hapticsOnRelease) { + hapticsProvider.provideFeedback( + HapticEffect.RELEASE, + hapticsView, + hapticsScale + ) + } + } else { + button.setPressedState(true) + if (hapticsOnPress) { + hapticsProvider.provideFeedback( + HapticEffect.PRESS, + hapticsView, + hapticsScale + ) + } + } button.trackId = event.getPointerId(pointerIndex) pressed = true InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) @@ -173,8 +200,16 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex MotionEvent.ACTION_POINTER_UP -> { // If a pointer ends, release the button it was pressing. if (button.trackId == event.getPointerId(pointerIndex)) { - if (!button.latching) + if (!button.latching) { button.setPressedState(false) + if (hapticsOnRelease) { + hapticsProvider.provideFeedback( + HapticEffect.RELEASE, + hapticsView, + hapticsScale + ) + } + } InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) val analogControl = getAnalogControlForTrigger(button.control) @@ -227,12 +262,26 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex // Release the buttons first, then press for (i in dpadPressed.indices) { if (!dpadPressed[i]) { + if (hapticsOnRelease && dpad.isPressed(i)) { + hapticsProvider.provideFeedback( + HapticEffect.RELEASE, + hapticsView, + hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), 0.0 ) } else { + if (hapticsOnPress && !dpad.isPressed(i)) { + hapticsProvider.provideFeedback( + HapticEffect.PRESS, + hapticsView, + hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), @@ -240,8 +289,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex ) } } - setDpadState( - dpad, + dpad.setPressed( dpadPressed[0], dpadPressed[1], dpadPressed[2], @@ -255,13 +303,20 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex // If a pointer ends, release the buttons. if (dpad.trackId == event.getPointerId(pointerIndex)) { for (i in 0 until 4) { - dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT) + if (hapticsOnRelease && dpad.isPressed(i)) { + hapticsProvider.provideFeedback( + HapticEffect.RELEASE, + hapticsView, + hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), 0.0 ) } + dpad.setPressed(up = false, down = false, left = false, right = false) dpad.trackId = -1 } } @@ -269,7 +324,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex } for (joystick in overlayJoysticks) { - if (joystick.trackEvent(event)) { + if (joystick.trackEvent(v, event)) { if (joystick.trackId != -1) pressed = true } @@ -455,40 +510,6 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex else -> -1 } - private fun setDpadState( - dpad: InputOverlayDrawableDpad, - up: Boolean, - down: Boolean, - left: Boolean, - right: Boolean - ) { - if (up) { - if (left) { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT) - } else { - if (right) { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT) - } else { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP) - } - } - } else if (down) { - if (left) { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT) - } else { - if (right) { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT) - } else { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN) - } - } - } else if (left) { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT) - } else if (right) { - dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT) - } - } - private fun addGameCubeOverlayControls(orientation: String) { if (BooleanSetting.MAIN_BUTTON_TOGGLE_GC_0.boolean) { overlayButtons.add( @@ -1349,7 +1370,8 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex legacyId, xControl, yControl, - controllerIndex + controllerIndex, + hapticsProvider ) // Need to set the image's position diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableDpad.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableDpad.kt index 2b53046a1e..05945603ad 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableDpad.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableDpad.kt @@ -45,6 +45,7 @@ class InputOverlayDrawableDpad( private val defaultStateBitmap: BitmapDrawable private val pressedOneDirectionStateBitmap: BitmapDrawable private val pressedTwoDirectionsStateBitmap: BitmapDrawable + private val pressedArray = BooleanArray(4) private var pressState = STATE_DEFAULT init { @@ -171,10 +172,32 @@ class InputOverlayDrawableDpad( val bounds: Rect get() = defaultStateBitmap.bounds - fun setState(pressState: Int) { - this.pressState = pressState + fun setPressed(up: Boolean, down: Boolean, left: Boolean, right: Boolean) { + pressedArray[0] = up + pressedArray[1] = down + pressedArray[2] = left + pressedArray[3] = right + pressState = when { + up -> when { + left -> STATE_PRESSED_UP_LEFT + right -> STATE_PRESSED_UP_RIGHT + else -> STATE_PRESSED_UP + } + + down -> when { + left -> STATE_PRESSED_DOWN_LEFT + right -> STATE_PRESSED_DOWN_RIGHT + else -> STATE_PRESSED_DOWN + } + + left -> STATE_PRESSED_LEFT + right -> STATE_PRESSED_RIGHT + else -> STATE_DEFAULT + } } + fun isPressed(index: Int): Boolean = pressedArray.getOrNull(index) ?: false + companion object { const val STATE_DEFAULT = 0 const val STATE_PRESSED_UP = 1 diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt index 7dff339dcf..71c7e2a709 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlayDrawableJoystick.kt @@ -8,11 +8,16 @@ import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent +import android.view.View import org.dolphinemu.dolphinemu.features.input.model.InputOverrider import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.FloatSetting +import org.dolphinemu.dolphinemu.model.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.hypot +import kotlin.math.pow import kotlin.math.sin /** @@ -28,6 +33,7 @@ import kotlin.math.sin * @param legacyId Legacy identifier (ButtonType) for which joystick this is. * @param xControl The control which the x value of the joystick will be written to. * @param yControl The control which the y value of the joystick will be written to. + * @param hapticsProvider An instance of [HapticsProvider] for providing haptic feedback. */ class InputOverlayDrawableJoystick( res: Resources, @@ -39,7 +45,8 @@ class InputOverlayDrawableJoystick( val legacyId: Int, val xControl: Int, val yControl: Int, - private val controllerIndex: Int + private val controllerIndex: Int, + private val hapticsProvider: HapticsProvider ) { var x = 0.0f private set @@ -47,6 +54,9 @@ class InputOverlayDrawableJoystick( private set var trackId = -1 private set + private var maxRadius = 0.0 + private var hapticsPreviousX = 0.0f + private var hapticsPreviousY = 0.0f private var controlPositionX = 0 private var controlPositionY = 0 private var previousTouchX = 0 @@ -94,12 +104,15 @@ class InputOverlayDrawableJoystick( boundsBoxBitmap.draw(canvas) } - fun trackEvent(event: MotionEvent): Boolean { + fun trackEvent(v: View, event: MotionEvent): Boolean { val reCenter = BooleanSetting.MAIN_JOYSTICK_REL_CENTER.boolean val action = event.actionMasked val firstPointer = action != MotionEvent.ACTION_POINTER_DOWN && action != MotionEvent.ACTION_POINTER_UP val pointerIndex = if (firstPointer) 0 else event.actionIndex + val hapticsScale = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float + val hapticsView = + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.boolean) null else v var pressed = false when (action) { @@ -112,6 +125,13 @@ class InputOverlayDrawableJoystick( ) { pressed = true pressedState = true + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean) { + hapticsProvider.provideFeedback( + HapticEffect.PRESS, + hapticsView, + hapticsScale + ) + } outerBitmap.alpha = 0 boundsBoxBitmap.alpha = opacity if (reCenter) { @@ -130,8 +150,17 @@ class InputOverlayDrawableJoystick( if (trackId == event.getPointerId(pointerIndex)) { pressed = true pressedState = false + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean) { + hapticsProvider.provideFeedback( + HapticEffect.RELEASE, + hapticsView, + hapticsScale + ) + } y = 0f x = y + hapticsPreviousX = x + hapticsPreviousY = y outerBitmap.alpha = opacity boundsBoxBitmap.alpha = 0 virtBounds = @@ -161,6 +190,20 @@ class InputOverlayDrawableJoystick( y = touchY / maxY setInnerBounds() + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean) { + val radiusThreshold = maxRadius * 0.1 + val deltaX = x - hapticsPreviousX + val deltaY = y - hapticsPreviousY + if (deltaX.pow(2) + deltaY.pow(2) > radiusThreshold.pow(2)) { + hapticsProvider.provideFeedback( + HapticEffect.JOYSTICK, + hapticsView, + hapticsScale + ) + hapticsPreviousX = x + hapticsPreviousY = y + } + } } } return pressed @@ -211,7 +254,7 @@ class InputOverlayDrawableJoystick( val angle = atan2(y, x) + Math.PI + Math.PI val radius = hypot(y, x) - val maxRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) + maxRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) if (radius > maxRadius) { x = maxRadius * cos(angle) y = maxRadius * sin(angle) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt new file mode 100644 index 0000000000..b865c9884f --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.utils + +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.view.View +import androidx.annotation.FloatRange +import androidx.core.view.ViewCompat +import org.dolphinemu.dolphinemu.features.input.model.DolphinVibratorManagerFactory +import org.dolphinemu.dolphinemu.model.HapticEffect + +/** + * Provides haptic feedback to the user. + * + * @property vibrator The [Vibrator] instance to be used for vibration. + * Defaults to the system's default vibrator. + */ +class HapticsProvider( + private val vibrator: Vibrator = + DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() +) { + private val supportedPrimitivesMap: Map + private val primitiveSupport: Boolean + + init { + supportedPrimitivesMap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticEffect.entries.associateWith { it.getSupportedPrimitive(vibrator) } + } else { + HapticEffect.entries.associateWith { null } + } + primitiveSupport = supportedPrimitivesMap.values.all { it != null } + } + + /** + * Perform haptic feedback natively (if a [View] is provided), + * or by composing primitives (if supported), falling back to a waveform or a legacy vibration. + * + * @param effect The [HapticEffect] of the feedback. + * @param view The [View] to perform the feedback on, can be null to use the [vibrator] directly. + * @param scale The intensity scale of the feedback, will only be used if [view] is not set. + */ + fun provideFeedback( + effect: HapticEffect, + view: View? = null, + @FloatRange(from = 0.0, to = 1.0) scale: Float = 0.5f + ) { + if (view != null) { + ViewCompat.performHapticFeedback(view, effect.feedbackConstant) + } else if (primitiveSupport && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + vibrator.vibrate( + VibrationEffect + .startComposition() + .addPrimitive(supportedPrimitivesMap[effect]!!, scale) + .compose() + ) + } else { + val scaledTimings = effect.scaleTimings(scale) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate( + VibrationEffect.createWaveform(scaledTimings, effect.scaleAmplitudes(scale), -1) + ) + } else { + vibrator.vibrate(scaledTimings.sum()) + } + } + } +} diff --git a/Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml b/Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml new file mode 100644 index 0000000000..d173910491 --- /dev/null +++ b/Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml b/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml index e4ef459487..427129070d 100644 --- a/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml +++ b/Source/Android/app/src/main/res/menu/menu_overlay_controls_gc.xml @@ -27,6 +27,10 @@ android:id="@+id/menu_emulation_choose_controller" android:title="@string/emulation_choose_controller"/> + + diff --git a/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml b/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml index 066889493f..6405c3fb6b 100644 --- a/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml +++ b/Source/Android/app/src/main/res/menu/menu_overlay_controls_wii.xml @@ -29,6 +29,10 @@ android:id="@+id/menu_emulation_choose_controller" android:title="@string/emulation_choose_controller"/> + + diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 04c48bcba4..e47c6d4088 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -637,6 +637,7 @@ It can efficiently compress both junk data and encrypted Wii data. IR Mode IR Sensitivity Double tap button + Touch Haptics Enable Vibration @@ -841,6 +842,13 @@ It can efficiently compress both junk data and encrypted Wii data. Follow Drag + + On Press + On Release + Joystick + Use vibrator directly + Intensity + Button A Button B diff --git a/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp b/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp index 0b0609f010..444ac0a09b 100644 --- a/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp +++ b/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp @@ -66,7 +66,7 @@ jmethodID s_motion_event_get_source; jclass s_controller_interface_class; jmethodID s_controller_interface_register_input_device_listener; jmethodID s_controller_interface_unregister_input_device_listener; -jmethodID s_controller_interface_get_vibrator_manager; +jmethodID s_controller_interface_get_device_vibrator_manager; jmethodID s_controller_interface_get_system_vibrator_manager; jmethodID s_controller_interface_vibrate; @@ -771,7 +771,8 @@ private: void AddMotors(JNIEnv* env, jobject input_device) { jobject vibrator_manager = env->CallStaticObjectMethod( - s_controller_interface_class, s_controller_interface_get_vibrator_manager, input_device); + s_controller_interface_class, s_controller_interface_get_device_vibrator_manager, + input_device); AddMotorsFromManager(env, vibrator_manager); env->DeleteLocalRef(vibrator_manager); } @@ -883,8 +884,8 @@ InputBackend::InputBackend(ControllerInterface* controller_interface) env->GetStaticMethodID(s_controller_interface_class, "registerInputDeviceListener", "()V"); s_controller_interface_unregister_input_device_listener = env->GetStaticMethodID(s_controller_interface_class, "unregisterInputDeviceListener", "()V"); - s_controller_interface_get_vibrator_manager = - env->GetStaticMethodID(s_controller_interface_class, "getVibratorManager", + s_controller_interface_get_device_vibrator_manager = + env->GetStaticMethodID(s_controller_interface_class, "getDeviceVibratorManager", "(Landroid/view/InputDevice;)Lorg/dolphinemu/dolphinemu/features/" "input/model/DolphinVibratorManager;"); s_controller_interface_get_system_vibrator_manager = env->GetStaticMethodID(