From 3e67c545013c1dd78bd2dcab6598720a1ef1546b Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Sat, 4 Jan 2025 21:56:42 +0200 Subject: [PATCH 01/16] Haptic feedback support for overlay controls --- .../activities/EmulationActivity.kt | 70 +++++++++++ .../input/model/ControllerInterface.kt | 23 +--- .../input/model/DolphinVibratorManager.kt | 2 + .../model/DolphinVibratorManagerCompat.kt | 2 + .../model/DolphinVibratorManagerFactory.kt | 33 +++++ .../DolphinVibratorManagerPassthrough.kt | 2 + .../features/settings/model/BooleanSetting.kt | 18 +++ .../features/settings/model/FloatSetting.kt | 6 + .../dolphinemu/overlay/InputOverlay.kt | 94 ++++++++------ .../overlay/InputOverlayDrawableDpad.kt | 27 +++- .../overlay/InputOverlayDrawableJoystick.kt | 53 ++++++-- .../dolphinemu/utils/HapticsProvider.kt | 118 ++++++++++++++++++ .../main/res/layout/dialog_haptics_adjust.xml | 109 ++++++++++++++++ .../res/menu/menu_overlay_controls_gc.xml | 4 + .../res/menu/menu_overlay_controls_wii.xml | 4 + .../app/src/main/res/values/strings.xml | 8 ++ .../ControllerInterface/Android/Android.cpp | 9 +- 17 files changed, 510 insertions(+), 72 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/DolphinVibratorManagerFactory.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt create mode 100644 Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml 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 cb2b0f89c9..afd0dce245 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 @@ -29,6 +29,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.DialogHapticsAdjustBinding import org.dolphinemu.dolphinemu.databinding.DialogInputAdjustBinding import org.dolphinemu.dolphinemu.databinding.DialogNfcFiguresManagerBinding import org.dolphinemu.dolphinemu.features.infinitybase.InfinityConfig @@ -37,7 +38,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 @@ -58,6 +61,8 @@ 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.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import org.dolphinemu.dolphinemu.utils.ThemeHelper import kotlin.math.roundToInt @@ -412,6 +417,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_haptics).setVisible(false) + } popup.setOnMenuItemClickListener { item: MenuItem -> onOptionsItemSelected(item) } popup.show() } @@ -492,6 +503,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings() MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings() MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation() + MENU_ACTION_ADJUST_HAPTICS -> adjustHaptics() } } @@ -667,6 +679,62 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { .show() } + private fun adjustHaptics() { + val dialogBinding = DialogHapticsAdjustBinding.inflate(layoutInflater) + val hapticsProvider = HapticsProvider() + dialogBinding.apply { + val toggleIntensity = { isChecked: Boolean -> + hapticsIntensityName.isEnabled = isChecked + hapticsIntensitySlider.isEnabled = isChecked + hapticsIntensityValue.isEnabled = isChecked + } + val checkboxes = + listOf(hapticsPressCheckbox, hapticsReleaseCheckbox, hapticsJoystickCheckbox) + hapticsPressCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean + hapticsReleaseCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean + hapticsJoystickCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean + if (checkboxes.none { it.isChecked }) { + toggleIntensity(false) + } + checkboxes.forEach { checkbox -> + checkbox.setOnCheckedChangeListener { _, _ -> + toggleIntensity(checkboxes.any { it.isChecked }) + } + } + hapticsIntensitySlider.apply { + val setValueText = { value: Float -> + hapticsIntensityValue.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.LOW_TICK, value) + } + } + } + MaterialAlertDialogBuilder(this) + .setView(dialogBinding.root) + .setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> + BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.setBoolean( + settings, dialogBinding.hapticsPressCheckbox.isChecked + ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.setBoolean( + settings, dialogBinding.hapticsReleaseCheckbox.isChecked + ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.setBoolean( + settings, dialogBinding.hapticsJoystickCheckbox.isChecked + ) + FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.setFloat( + settings, dialogBinding.hapticsIntensitySlider.value + ) + } + .show() + } + private fun chooseDoubleTapButton() { val currentValue = IntSetting.MAIN_DOUBLE_TAP_BUTTON.int @@ -1059,6 +1127,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_HAPTICS = 39 init { buttonsActionsMap.apply { @@ -1072,6 +1141,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_haptics, MENU_ACTION_ADJUST_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 3bca59f7b7..ba5024ccce 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 @@ -8,7 +8,6 @@ import android.os.Build import android.os.Handler import android.os.VibrationEffect import android.os.Vibrator -import android.os.VibratorManager import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent @@ -105,27 +104,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 156d6ccd4c..7773b56c25 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 @@ -646,6 +646,24 @@ enum class BooleanSetting( "ButtonLatchingNunchukZ", false ), + MAIN_OVERLAY_HAPTICS_PRESS( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsPress", + false + ), + MAIN_OVERLAY_HAPTICS_RELEASE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsRelease", + false + ), + MAIN_OVERLAY_HAPTICS_JOYSTICK( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_ANDROID, + "OverlayHapticsJoystick", + 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 ec5bda11cc..96067380b7 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/overlay/InputOverlay.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt index 7964cc1ebc..519483c4d7 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.utils.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,9 @@ 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 pressFeedback = BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean + val releaseFeedback = BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean // Tracks if any button/joystick is pressed down var pressed = false @@ -154,7 +161,23 @@ 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 (releaseFeedback) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, + hapticsScale + ) + } + } else { + button.setPressedState(true) + if (pressFeedback) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_FALL, + hapticsScale + ) + } + } button.trackId = event.getPointerId(pointerIndex) pressed = true InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) @@ -173,8 +196,15 @@ 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 (releaseFeedback) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, + hapticsScale + ) + } + } InputOverrider.setControlState(controllerIndex, button.control, if (button.getPressedState()) 1.0 else 0.0) val analogControl = getAnalogControlForTrigger(button.control) @@ -227,12 +257,24 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex // Release the buttons first, then press for (i in dpadPressed.indices) { if (!dpadPressed[i]) { + if (releaseFeedback && dpad.isPressed(i)) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, + hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), 0.0 ) } else { + if (pressFeedback && !dpad.isPressed(i)) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_FALL, + hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), @@ -240,8 +282,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex ) } } - setDpadState( - dpad, + dpad.setPressed( dpadPressed[0], dpadPressed[1], dpadPressed[2], @@ -255,13 +296,19 @@ 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 (releaseFeedback && dpad.isPressed(i)) { + hapticsProvider.provideFeedback( + HapticEffect.QUICK_RISE, + hapticsScale + ) + } InputOverrider.setControlState( controllerIndex, dpad.getControl(i), 0.0 ) } + dpad.setPressed(false, false, false, false) dpad.trackId = -1 } } @@ -455,40 +502,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 +1362,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..65dcba2c78 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 @@ -10,6 +10,9 @@ import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent 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.utils.HapticEffect +import org.dolphinemu.dolphinemu.utils.HapticsProvider import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.hypot @@ -28,6 +31,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 +43,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 +52,11 @@ class InputOverlayDrawableJoystick( private set var trackId = -1 private set + private var angle = 0.0 + private var radius = 0.0 + private var gateRadius = 0.0 + private var previousRadius = 0.0 + private var previousAngle = 0.0 private var controlPositionX = 0 private var controlPositionY = 0 private var previousTouchX = 0 @@ -100,6 +110,7 @@ class InputOverlayDrawableJoystick( 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 var pressed = false when (action) { @@ -112,6 +123,9 @@ class InputOverlayDrawableJoystick( ) { pressed = true pressedState = true + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean) { + hapticsProvider.provideFeedback(HapticEffect.QUICK_FALL, hapticsScale) + } outerBitmap.alpha = 0 boundsBoxBitmap.alpha = opacity if (reCenter) { @@ -130,6 +144,9 @@ class InputOverlayDrawableJoystick( if (trackId == event.getPointerId(pointerIndex)) { pressed = true pressedState = false + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean) { + hapticsProvider.provideFeedback(HapticEffect.QUICK_RISE, hapticsScale) + } y = 0f x = y outerBitmap.alpha = opacity @@ -139,6 +156,8 @@ class InputOverlayDrawableJoystick( bounds = Rect(origBounds.left, origBounds.top, origBounds.right, origBounds.bottom) setInnerBounds() + previousRadius = 0.0 + previousAngle = 0.0 trackId = -1 } } @@ -161,6 +180,20 @@ class InputOverlayDrawableJoystick( y = touchY / maxY setInnerBounds() + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean) { + val radiusThreshold = gateRadius * 0.33 + val angularDistance = kotlin.math.abs(previousAngle - angle) + .let { kotlin.math.min(it, Math.PI + Math.PI - it) } + if (kotlin.math.abs(previousRadius - radius) > radiusThreshold + || (radius > radiusThreshold && + (angularDistance >= HAPTICS_MAX_ANGLE || (radius == gateRadius && + angularDistance * hapticsScale >= HAPTICS_MIN_ANGLE))) + ) { + hapticsProvider.provideFeedback(HapticEffect.LOW_TICK, hapticsScale) + previousRadius = radius + previousAngle = angle + } + } } } return pressed @@ -209,12 +242,13 @@ class InputOverlayDrawableJoystick( var x = x.toDouble() var y = y.toDouble() - val angle = atan2(y, x) + Math.PI + Math.PI - val radius = hypot(y, x) - val maxRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) - if (radius > maxRadius) { - x = maxRadius * cos(angle) - y = maxRadius * sin(angle) + angle = atan2(y, x) + Math.PI + Math.PI + radius = hypot(y, x) + gateRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) + if (radius > gateRadius) { + radius = gateRadius + x = gateRadius * cos(angle) + y = gateRadius * sin(angle) this.x = x.toFloat() this.y = y.toFloat() } @@ -255,4 +289,9 @@ class InputOverlayDrawableJoystick( boundsBoxBitmap.alpha = value } } + + companion object { + private const val HAPTICS_MIN_ANGLE = Math.PI / 20.0 + private const val HAPTICS_MAX_ANGLE = Math.PI / 4.0 + } } 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..437e1e48cb --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt @@ -0,0 +1,118 @@ +// 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 androidx.annotation.FloatRange +import androidx.annotation.RequiresApi +import org.dolphinemu.dolphinemu.features.input.model.DolphinVibratorManagerFactory + +/** + * Provides haptic feedback to the user. + * + * @property vibrator The [Vibrator] instance to be used for vibration. + * Defaults to the system default vibrator. + */ +class HapticsProvider( + private val vibrator: Vibrator = + DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() +) { + private val primitiveSupport: Boolean = areAllPrimitivesSupported() + + /** + * Perform haptic feedback by composing primitives (if supported), + * with a fallback to a waveform or a legacy vibration. + * + * @param effect The [HapticEffect] of the feedback. + * @param scale The intensity scale of the feedback. + */ + fun provideFeedback(effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float) { + if (primitiveSupport) { + vibrator.vibrate( + VibrationEffect + .startComposition() + .addPrimitive(getPrimitive(effect), scale) + .compose() + ) + } else { + val timings = getTimings(effect, scale) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (vibrator.hasAmplitudeControl()) { + vibrator.vibrate( + VibrationEffect.createWaveform( + timings, getAmplitudes(effect, scale), -1 + ) + ) + } else { + vibrator.vibrate(VibrationEffect.createWaveform(timings, -1)) + } + } else { + vibrator.vibrate(timings.sum()) + } + } + } + + /** + * Get the timings for a waveform vibration based on the [effect], scaled by [scale]. + * + * @param effect The [HapticEffect] of the vibration. + * @param scale The intensity scale of the vibration. + * @return The LongArray of scaled timings for the specified [effect]. + */ + private fun getTimings( + effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float + ): LongArray { + // Note: It is recommended that these values differ by a ratio of 1.4 or more, + // so the difference in the duration of the vibration can be easily perceived. + // Lower-end vibrators can't vibrate at all if the duration is too short. + return when (effect) { + HapticEffect.QUICK_FALL -> longArrayOf(0L, (100f * scale).toLong()) + HapticEffect.QUICK_RISE -> longArrayOf(0L, (70f * scale).toLong()) + HapticEffect.LOW_TICK -> longArrayOf(0L, (50f * scale).toLong()) + } + } + + /** + * Get the amplitudes for a waveform vibration based on the [effect], scaled by [scale]. + * + * @param effect The [HapticEffect] of the vibration. + * @param scale The intensity scale of the vibration. + * @return The IntArray of scaled amplitudes for the specified [effect]. + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun getAmplitudes( + effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float + ): IntArray { + // Note: It is recommended that these values differ by a ratio of 1.4 or more, + // so the difference in the amplitude of the vibration can be easily perceived. + return when (effect) { + HapticEffect.QUICK_FALL -> intArrayOf(0, (180 * scale).toInt()) + HapticEffect.QUICK_RISE -> intArrayOf(0, (128 * scale).toInt()) + HapticEffect.LOW_TICK -> intArrayOf(0, (90 * scale).toInt()) + } + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun getPrimitive(effect: HapticEffect): Int { + return when (effect) { + HapticEffect.QUICK_FALL -> VibrationEffect.Composition.PRIMITIVE_QUICK_FALL + HapticEffect.QUICK_RISE -> VibrationEffect.Composition.PRIMITIVE_QUICK_RISE + HapticEffect.LOW_TICK -> VibrationEffect.Composition.PRIMITIVE_LOW_TICK + } + } + + private fun areAllPrimitivesSupported(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibrator.areAllPrimitivesSupported( + *HapticEffect.values().map { getPrimitive(it) }.toIntArray() + ) + } + +} + +enum class HapticEffect { + QUICK_FALL, + QUICK_RISE, + LOW_TICK +} diff --git a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml new file mode 100644 index 0000000000..7b9591fb8c --- /dev/null +++ b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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..a946ebb4f3 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..383555bd81 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 aacc57a3be..09b10331d5 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -616,6 +616,7 @@ It can efficiently compress both junk data and encrypted Wii data. IR Mode IR Sensitivity Double tap button + Touch Haptics Enable Vibration @@ -818,6 +819,13 @@ It can efficiently compress both junk data and encrypted Wii data. Follow Drag + + Feedback Triggers + Press + Release + Joystick + Intensity + Button A Button B diff --git a/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp b/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp index b6e03e73a0..05fcabdffd 100644 --- a/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp +++ b/Source/Core/InputCommon/ControllerInterface/Android/Android.cpp @@ -64,7 +64,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; @@ -747,7 +747,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); } @@ -858,8 +859,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( From a164e2bcc3aea55394d1a01dc043add138eb0588 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:41:32 +0300 Subject: [PATCH 02/16] Android/overlay-haptics: Rework joystick feedback --- .../overlay/InputOverlayDrawableJoystick.kt | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) 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 65dcba2c78..0b3daa07ed 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 @@ -16,6 +16,7 @@ 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 /** @@ -52,11 +53,9 @@ class InputOverlayDrawableJoystick( private set var trackId = -1 private set - private var angle = 0.0 - private var radius = 0.0 - private var gateRadius = 0.0 - private var previousRadius = 0.0 - private var previousAngle = 0.0 + 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 @@ -149,6 +148,8 @@ class InputOverlayDrawableJoystick( } y = 0f x = y + hapticsPreviousX = x + hapticsPreviousY = y outerBitmap.alpha = opacity boundsBoxBitmap.alpha = 0 virtBounds = @@ -156,8 +157,6 @@ class InputOverlayDrawableJoystick( bounds = Rect(origBounds.left, origBounds.top, origBounds.right, origBounds.bottom) setInnerBounds() - previousRadius = 0.0 - previousAngle = 0.0 trackId = -1 } } @@ -181,17 +180,13 @@ class InputOverlayDrawableJoystick( setInnerBounds() if (BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean) { - val radiusThreshold = gateRadius * 0.33 - val angularDistance = kotlin.math.abs(previousAngle - angle) - .let { kotlin.math.min(it, Math.PI + Math.PI - it) } - if (kotlin.math.abs(previousRadius - radius) > radiusThreshold - || (radius > radiusThreshold && - (angularDistance >= HAPTICS_MAX_ANGLE || (radius == gateRadius && - angularDistance * hapticsScale >= HAPTICS_MIN_ANGLE))) - ) { + 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.LOW_TICK, hapticsScale) - previousRadius = radius - previousAngle = angle + hapticsPreviousX = x + hapticsPreviousY = y } } } @@ -242,13 +237,12 @@ class InputOverlayDrawableJoystick( var x = x.toDouble() var y = y.toDouble() - angle = atan2(y, x) + Math.PI + Math.PI - radius = hypot(y, x) - gateRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) - if (radius > gateRadius) { - radius = gateRadius - x = gateRadius * cos(angle) - y = gateRadius * sin(angle) + val angle = atan2(y, x) + Math.PI + Math.PI + val radius = hypot(y, x) + maxRadius = InputOverrider.getGateRadiusAtAngle(controllerIndex, xControl, angle) + if (radius > maxRadius) { + x = maxRadius * cos(angle) + y = maxRadius * sin(angle) this.x = x.toFloat() this.y = y.toFloat() } @@ -289,9 +283,4 @@ class InputOverlayDrawableJoystick( boundsBoxBitmap.alpha = value } } - - companion object { - private const val HAPTICS_MIN_ANGLE = Math.PI / 20.0 - private const val HAPTICS_MAX_ANGLE = Math.PI / 4.0 - } } From 374c525e816f1ac906f860f370c495704397afe2 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:22:15 +0300 Subject: [PATCH 03/16] Android/overlay-haptics: Match menu item name with dialog title --- .../activities/EmulationActivity.kt | 15 +++++----- .../features/settings/model/BooleanSetting.kt | 8 +++--- .../dolphinemu/overlay/InputOverlay.kt | 16 +++++------ .../overlay/InputOverlayDrawableJoystick.kt | 4 +-- .../main/res/layout/dialog_haptics_adjust.xml | 28 +++++++++---------- .../app/src/main/res/values/strings.xml | 5 ++-- 6 files changed, 38 insertions(+), 38 deletions(-) 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 d2356a965b..06d63a19fe 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 @@ -721,9 +721,10 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { hapticsIntensityValue.isEnabled = isChecked } val checkboxes = - listOf(hapticsPressCheckbox, hapticsReleaseCheckbox, hapticsJoystickCheckbox) - hapticsPressCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean - hapticsReleaseCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean + listOf(hapticsOnPressCheckbox, hapticsOnReleaseCheckbox, hapticsJoystickCheckbox) + hapticsOnPressCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean + hapticsOnReleaseCheckbox.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean hapticsJoystickCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean if (checkboxes.none { it.isChecked }) { toggleIntensity(false) @@ -751,11 +752,11 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { MaterialAlertDialogBuilder(this) .setView(dialogBinding.root) .setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> - BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.setBoolean( - settings, dialogBinding.hapticsPressCheckbox.isChecked + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.setBoolean( + settings, dialogBinding.hapticsOnPressCheckbox.isChecked ) - BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.setBoolean( - settings, dialogBinding.hapticsReleaseCheckbox.isChecked + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.setBoolean( + settings, dialogBinding.hapticsOnReleaseCheckbox.isChecked ) BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.setBoolean( settings, dialogBinding.hapticsJoystickCheckbox.isChecked 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 4a0e5746aa..c2b2b2afd7 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 @@ -671,16 +671,16 @@ enum class BooleanSetting( "ButtonLatchingNunchukZ", false ), - MAIN_OVERLAY_HAPTICS_PRESS( + MAIN_OVERLAY_HAPTICS_ON_PRESS( Settings.FILE_DOLPHIN, Settings.SECTION_INI_ANDROID, - "OverlayHapticsPress", + "OverlayHapticsOnPress", false ), - MAIN_OVERLAY_HAPTICS_RELEASE( + MAIN_OVERLAY_HAPTICS_ON_RELEASE( Settings.FILE_DOLPHIN, Settings.SECTION_INI_ANDROID, - "OverlayHapticsRelease", + "OverlayHapticsOnRelease", false ), MAIN_OVERLAY_HAPTICS_JOYSTICK( 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 2488d149d9..9dcf2b2739 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 @@ -145,8 +145,8 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex action != MotionEvent.ACTION_POINTER_UP val pointerIndex = if (firstPointer) 0 else event.actionIndex val hapticsScale = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float - val pressFeedback = BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean - val releaseFeedback = BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean + val hapticsOnPress = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean + val hapticsOnRelease = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean // Tracks if any button/joystick is pressed down var pressed = false @@ -163,7 +163,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex ) { if (button.latching && button.getPressedState()) { button.setPressedState(false) - if (releaseFeedback) { + if (hapticsOnRelease) { hapticsProvider.provideFeedback( HapticEffect.QUICK_RISE, hapticsScale @@ -171,7 +171,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex } } else { button.setPressedState(true) - if (pressFeedback) { + if (hapticsOnPress) { hapticsProvider.provideFeedback( HapticEffect.QUICK_FALL, hapticsScale @@ -198,7 +198,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (button.trackId == event.getPointerId(pointerIndex)) { if (!button.latching) { button.setPressedState(false) - if (releaseFeedback) { + if (hapticsOnRelease) { hapticsProvider.provideFeedback( HapticEffect.QUICK_RISE, hapticsScale @@ -257,7 +257,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex // Release the buttons first, then press for (i in dpadPressed.indices) { if (!dpadPressed[i]) { - if (releaseFeedback && dpad.isPressed(i)) { + if (hapticsOnRelease && dpad.isPressed(i)) { hapticsProvider.provideFeedback( HapticEffect.QUICK_RISE, hapticsScale @@ -269,7 +269,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex 0.0 ) } else { - if (pressFeedback && !dpad.isPressed(i)) { + if (hapticsOnPress && !dpad.isPressed(i)) { hapticsProvider.provideFeedback( HapticEffect.QUICK_FALL, hapticsScale @@ -296,7 +296,7 @@ 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) { - if (releaseFeedback && dpad.isPressed(i)) { + if (hapticsOnRelease && dpad.isPressed(i)) { hapticsProvider.provideFeedback( HapticEffect.QUICK_RISE, hapticsScale 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 0b3daa07ed..4814783c4d 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 @@ -122,7 +122,7 @@ class InputOverlayDrawableJoystick( ) { pressed = true pressedState = true - if (BooleanSetting.MAIN_OVERLAY_HAPTICS_PRESS.boolean) { + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean) { hapticsProvider.provideFeedback(HapticEffect.QUICK_FALL, hapticsScale) } outerBitmap.alpha = 0 @@ -143,7 +143,7 @@ class InputOverlayDrawableJoystick( if (trackId == event.getPointerId(pointerIndex)) { pressed = true pressedState = false - if (BooleanSetting.MAIN_OVERLAY_HAPTICS_RELEASE.boolean) { + if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean) { hapticsProvider.provideFeedback(HapticEffect.QUICK_RISE, hapticsScale) } y = 0f diff --git a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml index 7b9591fb8c..5004124a37 100644 --- a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml +++ b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml @@ -7,49 +7,49 @@ android:orientation="vertical"> + app:layout_constraintTop_toTopOf="@id/haptics_on_release_checkbox" /> + app:layout_constraintStart_toEndOf="@+id/haptics_on_press_checkbox" + app:layout_constraintTop_toBottomOf="@+id/emulation_haptics_text" /> + app:layout_constraintStart_toEndOf="@+id/haptics_on_release_checkbox" + app:layout_constraintTop_toTopOf="@id/haptics_on_release_checkbox" /> Drag - Feedback Triggers - Press - Release + On Press + On Release Joystick Intensity From 4df53b547352b2314b8ced5bd62a1dbaf2de0445 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:13:09 +0300 Subject: [PATCH 04/16] Android/overlay-haptics: Enable by default on press & release --- .../dolphinemu/features/settings/model/BooleanSetting.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c2b2b2afd7..1c0bbd586a 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 @@ -675,13 +675,13 @@ enum class BooleanSetting( Settings.FILE_DOLPHIN, Settings.SECTION_INI_ANDROID, "OverlayHapticsOnPress", - false + true ), MAIN_OVERLAY_HAPTICS_ON_RELEASE( Settings.FILE_DOLPHIN, Settings.SECTION_INI_ANDROID, "OverlayHapticsOnRelease", - false + true ), MAIN_OVERLAY_HAPTICS_JOYSTICK( Settings.FILE_DOLPHIN, From cc3a61a522d0503d90514d27bd77ee6684cc7409 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:15:12 +0300 Subject: [PATCH 05/16] Android/overlay-haptics: Increase amplitude --- .../org/dolphinemu/dolphinemu/utils/HapticsProvider.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 437e1e48cb..074ec73816 100644 --- 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 @@ -87,10 +87,11 @@ class HapticsProvider( ): IntArray { // Note: It is recommended that these values differ by a ratio of 1.4 or more, // so the difference in the amplitude of the vibration can be easily perceived. + // Value range is between 0 and 255 (VibrationEffect.MAX_AMPLITUDE). return when (effect) { - HapticEffect.QUICK_FALL -> intArrayOf(0, (180 * scale).toInt()) - HapticEffect.QUICK_RISE -> intArrayOf(0, (128 * scale).toInt()) - HapticEffect.LOW_TICK -> intArrayOf(0, (90 * scale).toInt()) + HapticEffect.QUICK_FALL -> intArrayOf(0, (255f * scale).toInt()) + HapticEffect.QUICK_RISE -> intArrayOf(0, (180f * scale).toInt()) + HapticEffect.LOW_TICK -> intArrayOf(0, (128f * scale).toInt()) } } From 06eaeeced9c19779e89904eed74bdc8728ea501a Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:13:14 +0300 Subject: [PATCH 06/16] Android/overlay-haptics: Replace Enum.values() with Enum.entries --- .../java/org/dolphinemu/dolphinemu/utils/HapticsProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 074ec73816..c840e8d315 100644 --- 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 @@ -106,7 +106,7 @@ class HapticsProvider( private fun areAllPrimitivesSupported(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibrator.areAllPrimitivesSupported( - *HapticEffect.values().map { getPrimitive(it) }.toIntArray() + *HapticEffect.entries.map { getPrimitive(it) }.toIntArray() ) } From ed5d75f0a995370177070f2c436e8c2fa88b0a66 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:38:56 +0300 Subject: [PATCH 07/16] Android/overlay-haptics: Rename HapticEffects --- .../activities/EmulationActivity.kt | 2 +- .../dolphinemu/overlay/InputOverlay.kt | 12 +++++----- .../overlay/InputOverlayDrawableJoystick.kt | 6 ++--- .../dolphinemu/utils/HapticsProvider.kt | 24 +++++++++---------- 4 files changed, 22 insertions(+), 22 deletions(-) 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 06d63a19fe..148f5d5e69 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 @@ -745,7 +745,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { value = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float.also { setValueText(it) } addOnChangeListener { _: Slider, value: Float, _: Boolean -> setValueText(value) - hapticsProvider.provideFeedback(HapticEffect.LOW_TICK, value) + hapticsProvider.provideFeedback(HapticEffect.JOYSTICK, value) } } } 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 9dcf2b2739..9b4fbc5265 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 @@ -165,7 +165,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex button.setPressedState(false) if (hapticsOnRelease) { hapticsProvider.provideFeedback( - HapticEffect.QUICK_RISE, + HapticEffect.RELEASE, hapticsScale ) } @@ -173,7 +173,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex button.setPressedState(true) if (hapticsOnPress) { hapticsProvider.provideFeedback( - HapticEffect.QUICK_FALL, + HapticEffect.PRESS, hapticsScale ) } @@ -200,7 +200,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex button.setPressedState(false) if (hapticsOnRelease) { hapticsProvider.provideFeedback( - HapticEffect.QUICK_RISE, + HapticEffect.RELEASE, hapticsScale ) } @@ -259,7 +259,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (!dpadPressed[i]) { if (hapticsOnRelease && dpad.isPressed(i)) { hapticsProvider.provideFeedback( - HapticEffect.QUICK_RISE, + HapticEffect.RELEASE, hapticsScale ) } @@ -271,7 +271,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex } else { if (hapticsOnPress && !dpad.isPressed(i)) { hapticsProvider.provideFeedback( - HapticEffect.QUICK_FALL, + HapticEffect.PRESS, hapticsScale ) } @@ -298,7 +298,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex for (i in 0 until 4) { if (hapticsOnRelease && dpad.isPressed(i)) { hapticsProvider.provideFeedback( - HapticEffect.QUICK_RISE, + HapticEffect.RELEASE, hapticsScale ) } 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 4814783c4d..49977fa27f 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 @@ -123,7 +123,7 @@ class InputOverlayDrawableJoystick( pressed = true pressedState = true if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean) { - hapticsProvider.provideFeedback(HapticEffect.QUICK_FALL, hapticsScale) + hapticsProvider.provideFeedback(HapticEffect.PRESS, hapticsScale) } outerBitmap.alpha = 0 boundsBoxBitmap.alpha = opacity @@ -144,7 +144,7 @@ class InputOverlayDrawableJoystick( pressed = true pressedState = false if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean) { - hapticsProvider.provideFeedback(HapticEffect.QUICK_RISE, hapticsScale) + hapticsProvider.provideFeedback(HapticEffect.RELEASE, hapticsScale) } y = 0f x = y @@ -184,7 +184,7 @@ class InputOverlayDrawableJoystick( val deltaX = x - hapticsPreviousX val deltaY = y - hapticsPreviousY if (deltaX.pow(2) + deltaY.pow(2) > radiusThreshold.pow(2)) { - hapticsProvider.provideFeedback(HapticEffect.LOW_TICK, hapticsScale) + hapticsProvider.provideFeedback(HapticEffect.JOYSTICK, hapticsScale) hapticsPreviousX = x hapticsPreviousY = y } 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 index c840e8d315..be8bee9246 100644 --- 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 @@ -68,9 +68,9 @@ class HapticsProvider( // so the difference in the duration of the vibration can be easily perceived. // Lower-end vibrators can't vibrate at all if the duration is too short. return when (effect) { - HapticEffect.QUICK_FALL -> longArrayOf(0L, (100f * scale).toLong()) - HapticEffect.QUICK_RISE -> longArrayOf(0L, (70f * scale).toLong()) - HapticEffect.LOW_TICK -> longArrayOf(0L, (50f * scale).toLong()) + HapticEffect.PRESS -> longArrayOf(0L, (100f * scale).toLong()) + HapticEffect.RELEASE -> longArrayOf(0L, (70f * scale).toLong()) + HapticEffect.JOYSTICK -> longArrayOf(0L, (50f * scale).toLong()) } } @@ -89,18 +89,18 @@ class HapticsProvider( // so the difference in the amplitude of the vibration can be easily perceived. // Value range is between 0 and 255 (VibrationEffect.MAX_AMPLITUDE). return when (effect) { - HapticEffect.QUICK_FALL -> intArrayOf(0, (255f * scale).toInt()) - HapticEffect.QUICK_RISE -> intArrayOf(0, (180f * scale).toInt()) - HapticEffect.LOW_TICK -> intArrayOf(0, (128f * scale).toInt()) + HapticEffect.PRESS -> intArrayOf(0, (255f * scale).toInt()) + HapticEffect.RELEASE -> intArrayOf(0, (180f * scale).toInt()) + HapticEffect.JOYSTICK -> intArrayOf(0, (128f * scale).toInt()) } } @RequiresApi(Build.VERSION_CODES.S) private fun getPrimitive(effect: HapticEffect): Int { return when (effect) { - HapticEffect.QUICK_FALL -> VibrationEffect.Composition.PRIMITIVE_QUICK_FALL - HapticEffect.QUICK_RISE -> VibrationEffect.Composition.PRIMITIVE_QUICK_RISE - HapticEffect.LOW_TICK -> VibrationEffect.Composition.PRIMITIVE_LOW_TICK + HapticEffect.PRESS -> VibrationEffect.Composition.PRIMITIVE_QUICK_FALL + HapticEffect.RELEASE -> VibrationEffect.Composition.PRIMITIVE_QUICK_RISE + HapticEffect.JOYSTICK -> VibrationEffect.Composition.PRIMITIVE_LOW_TICK } } @@ -113,7 +113,7 @@ class HapticsProvider( } enum class HapticEffect { - QUICK_FALL, - QUICK_RISE, - LOW_TICK + PRESS, + RELEASE, + JOYSTICK } From dd072f8833257acdd0d33130d15eae0f576da44e Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:12:06 +0300 Subject: [PATCH 08/16] Android/overlay-haptics: Use native haptic feedback by default, add option to use vibrator directly --- .../activities/EmulationActivity.kt | 32 ++++++++++----- .../features/settings/model/BooleanSetting.kt | 6 +++ .../dolphinemu/overlay/InputOverlay.kt | 10 ++++- .../overlay/InputOverlayDrawableJoystick.kt | 23 +++++++++-- .../dolphinemu/utils/HapticsProvider.kt | 40 ++++++++++++++++--- .../main/res/layout/dialog_haptics_adjust.xml | 36 +++++++++++++++-- .../app/src/main/res/values/strings.xml | 1 + 7 files changed, 124 insertions(+), 24 deletions(-) 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 148f5d5e69..de28efe7a4 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 @@ -715,25 +715,34 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { val dialogBinding = DialogHapticsAdjustBinding.inflate(layoutInflater) val hapticsProvider = HapticsProvider() dialogBinding.apply { - val toggleIntensity = { isChecked: Boolean -> - hapticsIntensityName.isEnabled = isChecked - hapticsIntensitySlider.isEnabled = isChecked - hapticsIntensityValue.isEnabled = isChecked - } val checkboxes = listOf(hapticsOnPressCheckbox, hapticsOnReleaseCheckbox, hapticsJoystickCheckbox) + val toggleVibrationSettings = { + checkboxes.any { it.isChecked }.let { enabled -> + hapticsUseVibratorDirectlyName.isEnabled = enabled + hapticsUseVibratorDirectlySwitch.isEnabled = enabled + (hapticsUseVibratorDirectlySwitch.isChecked && enabled).let { enabled -> + hapticsIntensityName.isEnabled = enabled + hapticsIntensitySlider.isEnabled = enabled + hapticsIntensityValue.isEnabled = enabled + } + } + } hapticsOnPressCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean hapticsOnReleaseCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean hapticsJoystickCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean - if (checkboxes.none { it.isChecked }) { - toggleIntensity(false) - } + hapticsUseVibratorDirectlySwitch.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.boolean + toggleVibrationSettings() checkboxes.forEach { checkbox -> checkbox.setOnCheckedChangeListener { _, _ -> - toggleIntensity(checkboxes.any { it.isChecked }) + toggleVibrationSettings() } } + hapticsUseVibratorDirectlySwitch.setOnCheckedChangeListener { _, _ -> + toggleVibrationSettings() + } hapticsIntensitySlider.apply { val setValueText = { value: Float -> hapticsIntensityValue.text = @@ -745,7 +754,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { value = FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.float.also { setValueText(it) } addOnChangeListener { _: Slider, value: Float, _: Boolean -> setValueText(value) - hapticsProvider.provideFeedback(HapticEffect.JOYSTICK, value) + hapticsProvider.provideFeedback(HapticEffect.JOYSTICK, null, value) } } } @@ -761,6 +770,9 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.setBoolean( settings, dialogBinding.hapticsJoystickCheckbox.isChecked ) + BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.setBoolean( + settings, dialogBinding.hapticsUseVibratorDirectlySwitch.isChecked + ) FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.setFloat( settings, dialogBinding.hapticsIntensitySlider.value ) 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 1c0bbd586a..16b4ba2337 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 @@ -689,6 +689,12 @@ enum class BooleanSetting( "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/overlay/InputOverlay.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt index 9b4fbc5265..bfe8e1e90b 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 @@ -147,6 +147,8 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex 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 @@ -166,6 +168,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (hapticsOnRelease) { hapticsProvider.provideFeedback( HapticEffect.RELEASE, + hapticsView, hapticsScale ) } @@ -174,6 +177,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (hapticsOnPress) { hapticsProvider.provideFeedback( HapticEffect.PRESS, + hapticsView, hapticsScale ) } @@ -201,6 +205,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (hapticsOnRelease) { hapticsProvider.provideFeedback( HapticEffect.RELEASE, + hapticsView, hapticsScale ) } @@ -260,6 +265,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (hapticsOnRelease && dpad.isPressed(i)) { hapticsProvider.provideFeedback( HapticEffect.RELEASE, + hapticsView, hapticsScale ) } @@ -272,6 +278,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (hapticsOnPress && !dpad.isPressed(i)) { hapticsProvider.provideFeedback( HapticEffect.PRESS, + hapticsView, hapticsScale ) } @@ -299,6 +306,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex if (hapticsOnRelease && dpad.isPressed(i)) { hapticsProvider.provideFeedback( HapticEffect.RELEASE, + hapticsView, hapticsScale ) } @@ -316,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 } 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 49977fa27f..a3f6826295 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,6 +8,7 @@ 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 @@ -103,13 +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) { @@ -123,7 +126,11 @@ class InputOverlayDrawableJoystick( pressed = true pressedState = true if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean) { - hapticsProvider.provideFeedback(HapticEffect.PRESS, hapticsScale) + hapticsProvider.provideFeedback( + HapticEffect.PRESS, + hapticsView, + hapticsScale + ) } outerBitmap.alpha = 0 boundsBoxBitmap.alpha = opacity @@ -144,7 +151,11 @@ class InputOverlayDrawableJoystick( pressed = true pressedState = false if (BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean) { - hapticsProvider.provideFeedback(HapticEffect.RELEASE, hapticsScale) + hapticsProvider.provideFeedback( + HapticEffect.RELEASE, + hapticsView, + hapticsScale + ) } y = 0f x = y @@ -184,7 +195,11 @@ class InputOverlayDrawableJoystick( val deltaX = x - hapticsPreviousX val deltaY = y - hapticsPreviousY if (deltaX.pow(2) + deltaY.pow(2) > radiusThreshold.pow(2)) { - hapticsProvider.provideFeedback(HapticEffect.JOYSTICK, hapticsScale) + hapticsProvider.provideFeedback( + HapticEffect.JOYSTICK, + hapticsView, + hapticsScale + ) hapticsPreviousX = x hapticsPreviousY = y } 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 index be8bee9246..b9c37e1d59 100644 --- 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 @@ -5,6 +5,8 @@ package org.dolphinemu.dolphinemu.utils import android.os.Build import android.os.VibrationEffect import android.os.Vibrator +import android.view.HapticFeedbackConstants +import android.view.View import androidx.annotation.FloatRange import androidx.annotation.RequiresApi import org.dolphinemu.dolphinemu.features.input.model.DolphinVibratorManagerFactory @@ -20,16 +22,29 @@ class HapticsProvider( DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() ) { private val primitiveSupport: Boolean = areAllPrimitivesSupported() + private val releaseHapticFeedbackConstant = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + HapticFeedbackConstants.VIRTUAL_KEY_RELEASE + } else { + HapticFeedbackConstants.VIRTUAL_KEY // Same as press feedback is better than no feedback + } /** - * Perform haptic feedback by composing primitives (if supported), - * with a fallback to a waveform or a legacy vibration. + * 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 scale The intensity scale of the feedback. + * @param view The [View] to perform the feedback on, can be null to vibrate using the [vibrator]. + * @param scale The intensity scale of the feedback, will only be used if [view] is not set. */ - fun provideFeedback(effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float) { - if (primitiveSupport) { + fun provideFeedback( + effect: HapticEffect, + view: View? = null, + @FloatRange(from = 0.0, to = 1.0) scale: Float = 0.5f + ) { + if (view != null) { + view.performHapticFeedback(getHapticFeedbackConstant(effect)) + } else if (primitiveSupport) { vibrator.vibrate( VibrationEffect .startComposition() @@ -95,6 +110,20 @@ class HapticsProvider( } } + /** + * Get the haptic feedback constant that matches the [effect]. + * + * @param effect The [HapticEffect] of the feedback. + * @return The matching [HapticFeedbackConstants] constant. + */ + private fun getHapticFeedbackConstant(effect: HapticEffect): Int { + return when (effect) { + HapticEffect.PRESS -> HapticFeedbackConstants.VIRTUAL_KEY + HapticEffect.RELEASE -> releaseHapticFeedbackConstant + HapticEffect.JOYSTICK -> HapticFeedbackConstants.CLOCK_TICK + } + } + @RequiresApi(Build.VERSION_CODES.S) private fun getPrimitive(effect: HapticEffect): Int { return when (effect) { @@ -109,7 +138,6 @@ class HapticsProvider( *HapticEffect.entries.map { getPrimitive(it) }.toIntArray() ) } - } enum class HapticEffect { diff --git a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml index 5004124a37..06ff0fac4b 100644 --- a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml +++ b/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml @@ -29,8 +29,8 @@ android:id="@+id/haptics_on_press_checkbox" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:minWidth="48dp" android:minHeight="48dp" + android:minWidth="48dp" android:text="@string/haptics_on_press" android:textAppearance="?android:textAppearanceListItem" app:layout_constraintBottom_toBottomOf="parent" @@ -42,8 +42,8 @@ android:id="@+id/haptics_on_release_checkbox" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:minWidth="48dp" android:minHeight="48dp" + android:minWidth="48dp" android:text="@string/haptics_on_release" android:textAppearance="?android:textAppearanceListItem" app:layout_constraintBottom_toBottomOf="parent" @@ -55,8 +55,8 @@ android:id="@+id/haptics_joystick_checkbox" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:minWidth="48dp" android:minHeight="48dp" + android:minWidth="48dp" android:text="@string/haptics_joystick" android:textAppearance="?android:textAppearanceListItem" app:layout_constraintBottom_toBottomOf="parent" @@ -65,6 +65,36 @@ app:layout_constraintTop_toTopOf="@id/haptics_on_release_checkbox" /> + + + + + + + On Press On Release Joystick + Use vibrator directly Intensity From 14413ee0749b620712a04164425fbae64a4599e9 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:31:27 +0300 Subject: [PATCH 09/16] Android/overlay-haptics: Use property access syntax --- .../org/dolphinemu/dolphinemu/activities/EmulationActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 de28efe7a4..3c14cc89cb 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 @@ -452,7 +452,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { if (!DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() .hasVibrator() ) { - menu.findItem(R.id.menu_emulation_haptics).setVisible(false) + menu.findItem(R.id.menu_emulation_haptics).isVisible = false } popup.setOnMenuItemClickListener { item: MenuItem -> onOptionsItemSelected(item) } popup.show() From 5d39ea1842f2590f2034d920290f2fd4f0a593c8 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:37:39 +0300 Subject: [PATCH 10/16] Android/overlay-haptics: Prefix 'overlay' to added xml resources --- .../activities/EmulationActivity.kt | 58 +++++++++------- ..._adjust.xml => dialog_overlay_haptics.xml} | 69 +++++++++---------- .../res/menu/menu_overlay_controls_gc.xml | 4 +- .../res/menu/menu_overlay_controls_wii.xml | 4 +- .../app/src/main/res/values/strings.xml | 14 ++-- 5 files changed, 77 insertions(+), 72 deletions(-) rename Source/Android/app/src/main/res/layout/{dialog_haptics_adjust.xml => dialog_overlay_haptics.xml} (64%) 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 3c14cc89cb..29e01afc55 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,7 +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.DialogHapticsAdjustBinding +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 @@ -452,7 +452,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { if (!DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() .hasVibrator() ) { - menu.findItem(R.id.menu_emulation_haptics).isVisible = false + menu.findItem(R.id.menu_emulation_touch_haptics).isVisible = false } popup.setOnMenuItemClickListener { item: MenuItem -> onOptionsItemSelected(item) } popup.show() @@ -529,7 +529,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings() MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings() MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation() - MENU_ACTION_ADJUST_HAPTICS -> adjustHaptics() + MENU_ACTION_ADJUST_OVERLAY_HAPTICS -> adjustOverlayHaptics() } } @@ -711,28 +711,34 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { .show() } - private fun adjustHaptics() { - val dialogBinding = DialogHapticsAdjustBinding.inflate(layoutInflater) + private fun adjustOverlayHaptics() { + val dialogBinding = DialogOverlayHapticsBinding.inflate(layoutInflater) val hapticsProvider = HapticsProvider() dialogBinding.apply { val checkboxes = - listOf(hapticsOnPressCheckbox, hapticsOnReleaseCheckbox, hapticsJoystickCheckbox) + listOf( + overlayHapticsOnPressCheckbox, + overlayHapticsOnReleaseCheckbox, + overlayHapticsJoystickCheckbox + ) val toggleVibrationSettings = { checkboxes.any { it.isChecked }.let { enabled -> - hapticsUseVibratorDirectlyName.isEnabled = enabled - hapticsUseVibratorDirectlySwitch.isEnabled = enabled - (hapticsUseVibratorDirectlySwitch.isChecked && enabled).let { enabled -> - hapticsIntensityName.isEnabled = enabled - hapticsIntensitySlider.isEnabled = enabled - hapticsIntensityValue.isEnabled = enabled + overlayHapticsUseVibratorDirectlyName.isEnabled = enabled + overlayHapticsUseVibratorDirectlySwitch.isEnabled = enabled + (overlayHapticsUseVibratorDirectlySwitch.isChecked && enabled).let { enabled -> + overlayHapticsIntensityName.isEnabled = enabled + overlayHapticsIntensitySlider.isEnabled = enabled + overlayHapticsIntensityValue.isEnabled = enabled } } } - hapticsOnPressCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean - hapticsOnReleaseCheckbox.isChecked = + overlayHapticsOnPressCheckbox.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.boolean + overlayHapticsOnReleaseCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.boolean - hapticsJoystickCheckbox.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean - hapticsUseVibratorDirectlySwitch.isChecked = + overlayHapticsJoystickCheckbox.isChecked = + BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.boolean + overlayHapticsUseVibratorDirectlySwitch.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.boolean toggleVibrationSettings() checkboxes.forEach { checkbox -> @@ -740,12 +746,12 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { toggleVibrationSettings() } } - hapticsUseVibratorDirectlySwitch.setOnCheckedChangeListener { _, _ -> + overlayHapticsUseVibratorDirectlySwitch.setOnCheckedChangeListener { _, _ -> toggleVibrationSettings() } - hapticsIntensitySlider.apply { + overlayHapticsIntensitySlider.apply { val setValueText = { value: Float -> - hapticsIntensityValue.text = + overlayHapticsIntensityValue.text = getString(R.string.slider_setting_value, value * 100f, '%') } stepSize = 0.1f @@ -762,19 +768,19 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { .setView(dialogBinding.root) .setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_PRESS.setBoolean( - settings, dialogBinding.hapticsOnPressCheckbox.isChecked + settings, dialogBinding.overlayHapticsOnPressCheckbox.isChecked ) BooleanSetting.MAIN_OVERLAY_HAPTICS_ON_RELEASE.setBoolean( - settings, dialogBinding.hapticsOnReleaseCheckbox.isChecked + settings, dialogBinding.overlayHapticsOnReleaseCheckbox.isChecked ) BooleanSetting.MAIN_OVERLAY_HAPTICS_JOYSTICK.setBoolean( - settings, dialogBinding.hapticsJoystickCheckbox.isChecked + settings, dialogBinding.overlayHapticsJoystickCheckbox.isChecked ) BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.setBoolean( - settings, dialogBinding.hapticsUseVibratorDirectlySwitch.isChecked + settings, dialogBinding.overlayHapticsUseVibratorDirectlySwitch.isChecked ) FloatSetting.MAIN_OVERLAY_HAPTICS_SCALE.setFloat( - settings, dialogBinding.hapticsIntensitySlider.value + settings, dialogBinding.overlayHapticsIntensitySlider.value ) } .show() @@ -1158,7 +1164,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_HAPTICS = 39 + const val MENU_ACTION_ADJUST_OVERLAY_HAPTICS = 39 init { buttonsActionsMap.apply { @@ -1172,7 +1178,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_haptics, MENU_ACTION_ADJUST_HAPTICS) + append(R.id.menu_emulation_touch_haptics, MENU_ACTION_ADJUST_OVERLAY_HAPTICS) } } diff --git a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml b/Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml similarity index 64% rename from Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml rename to Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml index 06ff0fac4b..ae89e9820f 100644 --- a/Source/Android/app/src/main/res/layout/dialog_haptics_adjust.xml +++ b/Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml @@ -7,133 +7,132 @@ android:orientation="vertical"> + app:layout_constraintTop_toTopOf="@id/overlay_haptics_on_release_checkbox" /> + app:layout_constraintEnd_toStartOf="@+id/overlay_haptics_joystick_checkbox" + app:layout_constraintStart_toEndOf="@+id/overlay_haptics_on_press_checkbox" + app:layout_constraintTop_toBottomOf="@+id/overlay_haptics_text" /> + app:layout_constraintStart_toEndOf="@+id/overlay_haptics_on_release_checkbox" + app:layout_constraintTop_toTopOf="@id/overlay_haptics_on_release_checkbox" /> + app:layout_constraintTop_toTopOf="@+id/overlay_haptics_use_vibrator_directly_switch" /> - 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 a946ebb4f3..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 @@ -28,8 +28,8 @@ android:title="@string/emulation_choose_controller"/> + android:id="@+id/menu_emulation_touch_haptics" + android:title="@string/emulation_touch_haptics"/> + android:id="@+id/menu_emulation_touch_haptics" + android:title="@string/emulation_touch_haptics"/> IR Mode IR Sensitivity Double tap button - Touch Haptics + Touch Haptics Enable Vibration @@ -841,12 +841,12 @@ It can efficiently compress both junk data and encrypted Wii data. Follow Drag - - On Press - On Release - Joystick - Use vibrator directly - Intensity + + On Press + On Release + Joystick + Use vibrator directly + Intensity Button A From ec02d83bf62f78f9bfa39cc116c544dd24d7533a Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:14:51 +0300 Subject: [PATCH 11/16] Android/overlay-haptics: Add names to Boolean call arguments --- .../main/java/org/dolphinemu/dolphinemu/overlay/InputOverlay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bfe8e1e90b..9b6bb23e05 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 @@ -316,7 +316,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex 0.0 ) } - dpad.setPressed(false, false, false, false) + dpad.setPressed(up = false, down = false, left = false, right = false) dpad.trackId = -1 } } From 8a3cdaf2aef458b7af41126bba0c546fd88d6ab3 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:25:31 +0300 Subject: [PATCH 12/16] Android/overlay-haptics: EmulationActivity formatting fix --- .../activities/EmulationActivity.kt | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) 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 29e01afc55..d1268e93a5 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 @@ -715,20 +715,19 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { val dialogBinding = DialogOverlayHapticsBinding.inflate(layoutInflater) val hapticsProvider = HapticsProvider() dialogBinding.apply { - val checkboxes = - listOf( - overlayHapticsOnPressCheckbox, - overlayHapticsOnReleaseCheckbox, - overlayHapticsJoystickCheckbox - ) + val checkboxes = listOf( + overlayHapticsOnPressCheckbox, + overlayHapticsOnReleaseCheckbox, + overlayHapticsJoystickCheckbox + ) val toggleVibrationSettings = { checkboxes.any { it.isChecked }.let { enabled -> overlayHapticsUseVibratorDirectlyName.isEnabled = enabled overlayHapticsUseVibratorDirectlySwitch.isEnabled = enabled - (overlayHapticsUseVibratorDirectlySwitch.isChecked && enabled).let { enabled -> - overlayHapticsIntensityName.isEnabled = enabled - overlayHapticsIntensitySlider.isEnabled = enabled - overlayHapticsIntensityValue.isEnabled = enabled + (overlayHapticsUseVibratorDirectlySwitch.isChecked && enabled).let { + overlayHapticsIntensityName.isEnabled = it + overlayHapticsIntensitySlider.isEnabled = it + overlayHapticsIntensityValue.isEnabled = it } } } @@ -741,10 +740,8 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { overlayHapticsUseVibratorDirectlySwitch.isChecked = BooleanSetting.MAIN_OVERLAY_HAPTICS_USE_VIBRATOR_DIRECTLY.boolean toggleVibrationSettings() - checkboxes.forEach { checkbox -> - checkbox.setOnCheckedChangeListener { _, _ -> - toggleVibrationSettings() - } + checkboxes.forEach { + it.setOnCheckedChangeListener { _, _ -> toggleVibrationSettings() } } overlayHapticsUseVibratorDirectlySwitch.setOnCheckedChangeListener { _, _ -> toggleVibrationSettings() From a5b9e8283e7d5c773c0821260a8e91c11cc856f2 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:35:19 +0300 Subject: [PATCH 13/16] Android/overlay-haptics: Refactor HapticProvider code to HapticEffect, add fallback primitives --- .../activities/EmulationActivity.kt | 2 +- .../dolphinemu/model/HapticEffect.kt | 95 ++++++++++++++ .../dolphinemu/overlay/InputOverlay.kt | 2 +- .../overlay/InputOverlayDrawableJoystick.kt | 2 +- .../dolphinemu/utils/HapticsProvider.kt | 120 +++--------------- 5 files changed, 119 insertions(+), 102 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HapticEffect.kt 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 d1268e93a5..67c7ad4194 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 @@ -59,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 @@ -66,7 +67,6 @@ 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.HapticEffect import org.dolphinemu.dolphinemu.utils.HapticsProvider import org.dolphinemu.dolphinemu.utils.RateLimiter import org.dolphinemu.dolphinemu.utils.ThemeHelper 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..965f3b4340 --- /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, 255), + 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, 180), + 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(0L, 50L), + maxAmplitudes = intArrayOf(0, 128), + 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 9b6bb23e05..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 @@ -31,7 +31,7 @@ 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.utils.HapticEffect +import org.dolphinemu.dolphinemu.model.HapticEffect import org.dolphinemu.dolphinemu.utils.HapticsProvider import java.util.Arrays 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 a3f6826295..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 @@ -12,7 +12,7 @@ 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.utils.HapticEffect +import org.dolphinemu.dolphinemu.model.HapticEffect import org.dolphinemu.dolphinemu.utils.HapticsProvider import kotlin.math.atan2 import kotlin.math.cos 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 index b9c37e1d59..b865c9884f 100644 --- 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 @@ -5,36 +5,40 @@ package org.dolphinemu.dolphinemu.utils import android.os.Build import android.os.VibrationEffect import android.os.Vibrator -import android.view.HapticFeedbackConstants import android.view.View import androidx.annotation.FloatRange -import androidx.annotation.RequiresApi +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 default vibrator. + * Defaults to the system's default vibrator. */ class HapticsProvider( private val vibrator: Vibrator = DolphinVibratorManagerFactory.getSystemVibratorManager().getDefaultVibrator() ) { - private val primitiveSupport: Boolean = areAllPrimitivesSupported() - private val releaseHapticFeedbackConstant = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - HapticFeedbackConstants.VIRTUAL_KEY_RELEASE + 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 { - HapticFeedbackConstants.VIRTUAL_KEY // Same as press feedback is better than no feedback + 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 vibrate using the [vibrator]. + * @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( @@ -43,105 +47,23 @@ class HapticsProvider( @FloatRange(from = 0.0, to = 1.0) scale: Float = 0.5f ) { if (view != null) { - view.performHapticFeedback(getHapticFeedbackConstant(effect)) - } else if (primitiveSupport) { + ViewCompat.performHapticFeedback(view, effect.feedbackConstant) + } else if (primitiveSupport && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { vibrator.vibrate( VibrationEffect .startComposition() - .addPrimitive(getPrimitive(effect), scale) + .addPrimitive(supportedPrimitivesMap[effect]!!, scale) .compose() ) } else { - val timings = getTimings(effect, scale) + val scaledTimings = effect.scaleTimings(scale) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (vibrator.hasAmplitudeControl()) { - vibrator.vibrate( - VibrationEffect.createWaveform( - timings, getAmplitudes(effect, scale), -1 - ) - ) - } else { - vibrator.vibrate(VibrationEffect.createWaveform(timings, -1)) - } + vibrator.vibrate( + VibrationEffect.createWaveform(scaledTimings, effect.scaleAmplitudes(scale), -1) + ) } else { - vibrator.vibrate(timings.sum()) + vibrator.vibrate(scaledTimings.sum()) } } } - - /** - * Get the timings for a waveform vibration based on the [effect], scaled by [scale]. - * - * @param effect The [HapticEffect] of the vibration. - * @param scale The intensity scale of the vibration. - * @return The LongArray of scaled timings for the specified [effect]. - */ - private fun getTimings( - effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float - ): LongArray { - // Note: It is recommended that these values differ by a ratio of 1.4 or more, - // so the difference in the duration of the vibration can be easily perceived. - // Lower-end vibrators can't vibrate at all if the duration is too short. - return when (effect) { - HapticEffect.PRESS -> longArrayOf(0L, (100f * scale).toLong()) - HapticEffect.RELEASE -> longArrayOf(0L, (70f * scale).toLong()) - HapticEffect.JOYSTICK -> longArrayOf(0L, (50f * scale).toLong()) - } - } - - /** - * Get the amplitudes for a waveform vibration based on the [effect], scaled by [scale]. - * - * @param effect The [HapticEffect] of the vibration. - * @param scale The intensity scale of the vibration. - * @return The IntArray of scaled amplitudes for the specified [effect]. - */ - @RequiresApi(Build.VERSION_CODES.O) - private fun getAmplitudes( - effect: HapticEffect, @FloatRange(from = 0.0, to = 1.0) scale: Float - ): IntArray { - // Note: It is recommended that these values differ by a ratio of 1.4 or more, - // so the difference in the amplitude of the vibration can be easily perceived. - // Value range is between 0 and 255 (VibrationEffect.MAX_AMPLITUDE). - return when (effect) { - HapticEffect.PRESS -> intArrayOf(0, (255f * scale).toInt()) - HapticEffect.RELEASE -> intArrayOf(0, (180f * scale).toInt()) - HapticEffect.JOYSTICK -> intArrayOf(0, (128f * scale).toInt()) - } - } - - /** - * Get the haptic feedback constant that matches the [effect]. - * - * @param effect The [HapticEffect] of the feedback. - * @return The matching [HapticFeedbackConstants] constant. - */ - private fun getHapticFeedbackConstant(effect: HapticEffect): Int { - return when (effect) { - HapticEffect.PRESS -> HapticFeedbackConstants.VIRTUAL_KEY - HapticEffect.RELEASE -> releaseHapticFeedbackConstant - HapticEffect.JOYSTICK -> HapticFeedbackConstants.CLOCK_TICK - } - } - - @RequiresApi(Build.VERSION_CODES.S) - private fun getPrimitive(effect: HapticEffect): Int { - return when (effect) { - HapticEffect.PRESS -> VibrationEffect.Composition.PRIMITIVE_QUICK_FALL - HapticEffect.RELEASE -> VibrationEffect.Composition.PRIMITIVE_QUICK_RISE - HapticEffect.JOYSTICK -> VibrationEffect.Composition.PRIMITIVE_LOW_TICK - } - } - - private fun areAllPrimitivesSupported(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibrator.areAllPrimitivesSupported( - *HapticEffect.entries.map { getPrimitive(it) }.toIntArray() - ) - } -} - -enum class HapticEffect { - PRESS, - RELEASE, - JOYSTICK } From de7c27dea8ad4e2e8135d29e98e97bf97e13bd85 Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:31:15 +0300 Subject: [PATCH 14/16] Android/overlay-haptics: Revert 'Increase amplitude' --- .../java/org/dolphinemu/dolphinemu/model/HapticEffect.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 965f3b4340..d2737ecb5b 100644 --- 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 @@ -28,7 +28,7 @@ enum class HapticEffect( PRESS( feedbackConstant = HapticFeedbackConstantsCompat.VIRTUAL_KEY, maxTimings = longArrayOf(0L, 100L), - maxAmplitudes = intArrayOf(0, 255), + maxAmplitudes = intArrayOf(0, 180), primaryPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { VibrationEffect.Composition.PRIMITIVE_QUICK_FALL } else { @@ -47,7 +47,7 @@ enum class HapticEffect( HapticFeedbackConstantsCompat.CONTEXT_CLICK // Better than a no-op. }, maxTimings = longArrayOf(0L, 70L), - maxAmplitudes = intArrayOf(0, 180), + maxAmplitudes = intArrayOf(0, 128), primaryPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { VibrationEffect.Composition.PRIMITIVE_QUICK_RISE } else { @@ -57,7 +57,7 @@ enum class HapticEffect( JOYSTICK( feedbackConstant = HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK, maxTimings = longArrayOf(0L, 50L), - maxAmplitudes = intArrayOf(0, 128), + maxAmplitudes = intArrayOf(0, 90), primaryPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { VibrationEffect.Composition.PRIMITIVE_LOW_TICK } else { From 26cb3f3c41ec777d60f24007e306b6fc2a21e0be Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:54:57 +0300 Subject: [PATCH 15/16] Android/overlay-haptics: Fix joystick fallback vibration --- .../main/java/org/dolphinemu/dolphinemu/model/HapticEffect.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index d2737ecb5b..2247e3547d 100644 --- 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 @@ -56,8 +56,8 @@ enum class HapticEffect( ), JOYSTICK( feedbackConstant = HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK, - maxTimings = longArrayOf(0L, 50L), - maxAmplitudes = intArrayOf(0, 90), + maxTimings = longArrayOf(50L), + maxAmplitudes = intArrayOf(90), primaryPrimitive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { VibrationEffect.Composition.PRIMITIVE_LOW_TICK } else { From 0f12ed1b39dbf738a1339b5027c20ae9bf267c4c Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:20:52 +0300 Subject: [PATCH 16/16] Android/overlay-haptics: Refactor xml --- .../activities/EmulationActivity.kt | 2 +- .../res/layout/dialog_overlay_haptics.xml | 83 ++++++++++--------- 2 files changed, 43 insertions(+), 42 deletions(-) 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 67c7ad4194..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 @@ -722,7 +722,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { ) val toggleVibrationSettings = { checkboxes.any { it.isChecked }.let { enabled -> - overlayHapticsUseVibratorDirectlyName.isEnabled = enabled + overlayHapticsUseVibratorDirectlyText.isEnabled = enabled overlayHapticsUseVibratorDirectlySwitch.isEnabled = enabled (overlayHapticsUseVibratorDirectlySwitch.isChecked && enabled).let { overlayHapticsIntensityName.isEnabled = it 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 index ae89e9820f..d173910491 100644 --- a/Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml +++ b/Source/Android/app/src/main/res/layout/dialog_overlay_haptics.xml @@ -4,26 +4,30 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical"> + android:orientation="vertical" + android:paddingHorizontal="@dimen/spacing_xtralarge" + android:paddingTop="@dimen/spacing_large"> - + android:layout_marginBottom="@dimen/spacing_small" + android:gravity="center"> + android:text="@string/emulation_touch_haptics" /> + + + + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" /> + android:layout_marginTop="@dimen/spacing_small"> + app:layout_constraintTop_toTopOf="parent" /> - @@ -99,31 +103,18 @@ android:id="@+id/overlay_haptics_intensity" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginTop="@dimen/spacing_small" - android:paddingHorizontal="24dp"> + android:layout_marginTop="@dimen/spacing_small"> - - + +