Android/overlay-haptics: Refactor HapticProvider code to HapticEffect, add fallback primitives

This commit is contained in:
codokie
2026-04-10 22:35:19 +03:00
parent 8a3cdaf2ae
commit a5b9e8283e
5 changed files with 119 additions and 102 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<HapticEffect, Int?>
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
}