android: gamepad rumble support (untested)

Use single thread to handle rumble for virtual and physical gamepads.
Use heavy click effect for vgamepad buttons when available (android 10)
Handle rumble inclination param.
This commit is contained in:
Flyinghead 2024-06-21 18:32:25 +02:00
parent af7edadedb
commit 9976fe10fb
5 changed files with 267 additions and 84 deletions

View File

@ -2,18 +2,15 @@ package com.flycast.emulator.emu;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Handler;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import com.flycast.emulator.Emulator;
import com.flycast.emulator.periph.InputDeviceManager;
import com.flycast.emulator.periph.VJoy;
import com.flycast.emulator.periph.VibratorThread;
public class VirtualJoystickDelegate {
private VibratorThread vibratorThread;
@ -41,19 +38,15 @@ public class VirtualJoystickDelegate {
this.view = view;
this.context = view.getContext();
vibratorThread = new VibratorThread(context);
vibratorThread.start();
vibratorThread = VibratorThread.getInstance();
readCustomVjoyValues();
scaleGestureDetector = new ScaleGestureDetector(context, new OscOnScaleGestureListener());
}
public void stop() {
vibratorThread.stopVibrator();
try {
vibratorThread.join();
} catch (InterruptedException e) {
}
vibratorThread.stopThread();
vibratorThread = null;
}
public void readCustomVjoyValues() {
@ -231,7 +224,7 @@ public class VirtualJoystickDelegate {
// Not for analog
if (vjoy[j][5] == 0)
if (!editVjoyMode) {
vibratorThread.vibrate();
vibratorThread.click();
}
vjoy[j][5] = 2;
}
@ -406,56 +399,4 @@ public class VirtualJoystickDelegate {
selectedVjoyElement = -1;
}
}
private class VibratorThread extends Thread
{
private Vibrator vibrator;
private boolean vibrate = false;
private boolean stopping = false;
VibratorThread(Context context) {
vibrator = (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE);
}
@Override
public void run() {
while (!stopping) {
boolean doVibrate;
synchronized (this) {
doVibrate = false;
try {
this.wait();
} catch (InterruptedException e) {
}
if (vibrate) {
doVibrate = true;
vibrate = false;
}
}
if (doVibrate) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(Emulator.vibrationDuration, VibrationEffect.DEFAULT_AMPLITUDE));
} else {
vibrator.vibrate(Emulator.vibrationDuration);
}
}
}
}
public void stopVibrator() {
synchronized (this) {
stopping = true;
notify();
}
}
public void vibrate() {
if (Emulator.vibrationDuration > 0) {
synchronized (this) {
vibrate = true;
notify();
}
}
}
}
}

View File

@ -10,7 +10,10 @@ import android.view.InputDevice;
import com.flycast.emulator.Emulator;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.ArrayUtils;
public final class InputDeviceManager implements InputManager.InputDeviceListener {
@ -21,6 +24,13 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
private InputManager inputManager;
private int maple_port = 0;
private static class VibrationParams {
float power;
float inclination;
long stopTime;
}
private Map<Integer, VibrationParams> vibParams = new HashMap<>();
public InputDeviceManager()
{
init();
@ -30,7 +40,8 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
{
maple_port = 0;
if (applicationContext.getPackageManager().hasSystemFeature("android.hardware.touchscreen"))
joystickAdded(VIRTUAL_GAMEPAD_ID, "Virtual Gamepad", 0, "virtual_gamepad_uid", new int[0], new int[0]);
joystickAdded(VIRTUAL_GAMEPAD_ID, "Virtual Gamepad", 0, "virtual_gamepad_uid",
new int[0], new int[0], getVibrator(VIRTUAL_GAMEPAD_ID) != null);
int[] ids = InputDevice.getDeviceIds();
for (int id : ids)
onInputDeviceAdded(id);
@ -65,7 +76,8 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
fullAxes.add(range.getAxis());
}
joystickAdded(i, device.getName(), port, device.getDescriptor(),
ArrayUtils.toPrimitive(fullAxes.toArray(new Integer[0])), ArrayUtils.toPrimitive(halfAxes.toArray(new Integer[0])));
ArrayUtils.toPrimitive(fullAxes.toArray(new Integer[0])), ArrayUtils.toPrimitive(halfAxes.toArray(new Integer[0])),
getVibrator(i) != null);
}
}
@ -80,34 +92,107 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
public void onInputDeviceChanged(int i) {
}
// Called from native code
private boolean rumble(int i, float power, float inclination, int duration_ms) {
Vibrator vibrator;
private Vibrator getVibrator(int i) {
if (i == VIRTUAL_GAMEPAD_ID) {
vibrator = (Vibrator)Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE);
return (Vibrator)Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE);
}
else {
InputDevice device = InputDevice.getDevice(i);
if (device == null)
return false;
vibrator = device.getVibrator();
if (!vibrator.hasVibrator())
return false;
return null;
Vibrator vibrator = device.getVibrator();
return vibrator.hasVibrator() ? vibrator : null;
}
}
if (power == 0) {
vibrator.cancel();
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, VibrationEffect.DEFAULT_AMPLITUDE));
} else {
vibrator.vibrate(duration_ms);
// Called from native code
private boolean rumble(int i, float power, float inclination, int duration_ms)
{
Vibrator vibrator = getVibrator(i);
if (vibrator == null)
return false;
VibrationParams params;
synchronized (this) {
params = vibParams.get(i);
if (params == null) {
params = new VibrationParams();
vibParams.put(i, params);
}
}
if (power == 0) {
if (params.power != 0)
vibrator.cancel();
} else {
if (inclination > 0) {
params.inclination = inclination * power;
params.stopTime = System.currentTimeMillis() + duration_ms;
}
else {
params.inclination = 0;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, (int)(power * 255)));
else
vibrator.vibrate(duration_ms);
if (inclination > 0)
VibratorThread.getInstance().setVibrating();
}
params.power = power;
return true;
}
public boolean updateRumble()
{
List<Integer> ids;
synchronized (this) {
ids = new ArrayList<Integer>(vibParams.keySet());
}
boolean active = false;
for (int id : ids) {
if (updateRumble(id))
active = true;
}
return active;
}
private boolean updateRumble(int i)
{
Vibrator vibrator = getVibrator(i);
VibrationParams params;
synchronized (this) {
params = vibParams.get(i);
}
if (vibrator == null || params == null || params.power == 0 || params.inclination == 0)
return false;
long remTime = params.stopTime - System.currentTimeMillis();
if (remTime <= 0) {
params.power = 0;
params.inclination = 0;
vibrator.cancel();
return false;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
vibrator.vibrate(VibrationEffect.createOneShot(remTime, (int)(params.inclination * remTime * 255)));
else
vibrator.vibrate(remTime);
return true;
}
public void stopRumble()
{
List<Integer> ids;
synchronized (this) {
ids = new ArrayList<Integer>(vibParams.keySet());
}
for (int id : ids) {
Vibrator vibrator = getVibrator(id);
if (vibrator != null)
vibrator.cancel();
}
}
public static InputDeviceManager getInstance() {
return INSTANCE;
}
@ -118,7 +203,7 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
public native boolean joystickAxisEvent(int id, int button, int value);
public native void mouseEvent(int xpos, int ypos, int buttons);
public native void mouseScrollEvent(int scrollValue);
private native void joystickAdded(int id, String name, int maple_port, String uniqueId, int[] fullAxes, int[] halfAxes);
private native void joystickAdded(int id, String name, int maple_port, String uniqueId, int[] fullAxes, int[] halfAxes, boolean rumbleEnabled);
private native void joystickRemoved(int id);
public native boolean keyboardEvent(int key, boolean pressed);
public native void keyboardText(int c);

View File

@ -0,0 +1,152 @@
/*
Copyright 2024 flyinghead
This file is part of Flycast.
Flycast is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
Flycast is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Flycast. If not, see <https://www.gnu.org/licenses/>.
*/
package com.flycast.emulator.periph;
import android.content.Context;
import android.os.Build;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.view.InputDevice;
import androidx.annotation.RequiresApi;
import com.flycast.emulator.Emulator;
public class VibratorThread extends Thread {
private boolean stopping = false;
private boolean click = false;
private long nextRumbleUpdate = 0;
@RequiresApi(Build.VERSION_CODES.O)
private VibrationEffect clickEffect = null;
int clickDuration = 0;
private static VibratorThread INSTANCE = null;
public static VibratorThread getInstance() {
synchronized (VibratorThread.class) {
if (INSTANCE == null)
INSTANCE = new VibratorThread();
}
return INSTANCE;
}
private VibratorThread() {
start();
}
private Vibrator getVibrator(int i)
{
if (i == InputDeviceManager.VIRTUAL_GAMEPAD_ID) {
return (Vibrator) Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE);
}
else {
InputDevice device = InputDevice.getDevice(i);
if (device == null)
return null;
Vibrator vibrator = device.getVibrator();
return vibrator.hasVibrator() ? vibrator : null;
}
}
@Override
public void run()
{
while (!stopping)
{
boolean doClick = false;
synchronized (this) {
try {
if (nextRumbleUpdate != 0) {
long waitTime = nextRumbleUpdate - System.currentTimeMillis();
if (waitTime > 0)
this.wait(waitTime);
}
else {
this.wait();
}
} catch (InterruptedException e) {
}
if (click) {
doClick = true;
click = false;
}
}
if (doClick)
doClick();
if (nextRumbleUpdate != 0 && nextRumbleUpdate - System.currentTimeMillis() < 5) {
if (!InputDeviceManager.getInstance().updateRumble())
nextRumbleUpdate = 0;
else
nextRumbleUpdate = System.currentTimeMillis() + 16667;
}
}
InputDeviceManager.getInstance().stopRumble();
}
public void stopThread() {
synchronized (this) {
stopping = true;
notify();
}
try {
join();
} catch (InterruptedException e) {
}
INSTANCE = null;
}
public void click() {
if (Emulator.vibrationDuration > 0) {
synchronized (this) {
click = true;
notify();
}
}
}
private void doClick()
{
Vibrator vibrator = getVibrator(InputDeviceManager.VIRTUAL_GAMEPAD_ID);
if (vibrator == null)
return;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
if (clickEffect == null || clickDuration != Emulator.vibrationDuration)
{
clickDuration = Emulator.vibrationDuration;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
clickEffect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK);
else
clickEffect = VibrationEffect.createOneShot(clickDuration, VibrationEffect.DEFAULT_AMPLITUDE);
}
vibrator.vibrate(clickEffect);
} else {
vibrator.vibrate(Emulator.vibrationDuration);
}
}
public void setVibrating()
{
// FIXME possible race condition
synchronized (this) {
if (nextRumbleUpdate == 0)
nextRumbleUpdate = System.currentTimeMillis() + 16667;
notify();
}
}
}

View File

@ -511,7 +511,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa
}
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceManager_joystickAdded(JNIEnv *env, jobject obj, jint id, jstring name,
jint maple_port, jstring junique_id, jintArray fullAxes, jintArray halfAxes)
jint maple_port, jstring junique_id, jintArray fullAxes, jintArray halfAxes, jboolean hasRumble)
{
std::string joyname = jni::String(name, false);
std::string unique_id = jni::String(junique_id, false);
@ -520,6 +520,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa
std::shared_ptr<AndroidGamepadDevice> gamepad = std::make_shared<AndroidGamepadDevice>(maple_port, id, joyname.c_str(), unique_id.c_str(), full, half);
AndroidGamepadDevice::AddAndroidGamepad(gamepad);
gamepad->setRumbleEnabled(hasRumble);
}
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceManager_joystickRemoved(JNIEnv *env, jobject obj, jint id)
{

View File

@ -106,7 +106,6 @@ public:
if (id == VIRTUAL_GAMEPAD_ID)
{
input_mapper = std::make_shared<IdentityInputMapping>();
rumbleEnabled = true;
// hasAnalogStick = true; // TODO has an analog stick but input mapping isn't persisted
}
else
@ -310,9 +309,14 @@ public:
void rumble(float power, float inclination, u32 duration_ms) override
{
power *= rumblePower / 100.f;
jboolean has_vibrator = jni::env()->CallBooleanMethod(input_device_manager, input_device_manager_rumble, android_id, power, inclination, duration_ms);
rumbleEnabled = has_vibrator;
}
void setRumbleEnabled(bool rumbleEnabled) {
this->rumbleEnabled = rumbleEnabled;
}
bool is_virtual_gamepad() override { return android_id == VIRTUAL_GAMEPAD_ID; }
bool hasHalfAxis(int axis) const { return std::find(halfAxes.begin(), halfAxes.end(), axis) != halfAxes.end(); }