Android/overlay-haptics: Use native haptic feedback by default, add option to use vibrator directly

This commit is contained in:
codokie
2026-04-10 19:12:06 +03:00
parent 9653ef60da
commit dd072f8833
7 changed files with 124 additions and 24 deletions

View File

@@ -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
)

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/haptics_use_vibrator_directly"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_small"
android:paddingHorizontal="24dp">
<TextView
android:id="@+id/haptics_use_vibrator_directly_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_large"
android:text="@string/haptics_use_vibrator_directly"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintBottom_toBottomOf="@+id/haptics_use_vibrator_directly_switch"
app:layout_constraintEnd_toStartOf="@+id/haptics_use_vibrator_directly_switch"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/haptics_use_vibrator_directly_switch" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/haptics_use_vibrator_directly_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/haptics_use_vibrator_directly_name"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/haptics_intensity"
android:layout_width="match_parent"

View File

@@ -845,6 +845,7 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="haptics_on_press">On Press</string>
<string name="haptics_on_release">On Release</string>
<string name="haptics_joystick">Joystick</string>
<string name="haptics_use_vibrator_directly">Use vibrator directly</string>
<string name="haptics_intensity">Intensity</string>
<!-- Double Tap Buttons -->