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] 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