Skip to content

Commit 1ff1e57

Browse files
Add gyro aiming + toggle in overlay
1 parent 460dadc commit 1ff1e57

21 files changed

Lines changed: 319 additions & 0 deletions

File tree

app/src/main/java/app/gamenative/ui/component/QuickMenu.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import androidx.compose.material.icons.filled.Gamepad
4646
import androidx.compose.material.icons.filled.Keyboard
4747
import androidx.compose.material.icons.filled.QueryStats
4848
import androidx.compose.material.icons.filled.TouchApp
49+
import androidx.compose.material.icons.filled.GpsFixed
4950
import androidx.compose.material3.HorizontalDivider
5051
import androidx.compose.material3.Icon
5152
import androidx.compose.material3.LinearProgressIndicator
@@ -91,6 +92,7 @@ object QuickMenuAction {
9192
const val EDIT_CONTROLS = 4
9293
const val EDIT_PHYSICAL_CONTROLLER = 5
9394
const val PERFORMANCE_HUD = 6
95+
const val GYRO_AIMING = 7
9496
}
9597

9698
private object QuickMenuTab {
@@ -246,6 +248,14 @@ fun QuickMenu(
246248
accentColor = PluviaTheme.colors.accentPurple,
247249
)
248250
)
251+
add(
252+
QuickMenuItem(
253+
id = QuickMenuAction.GYRO_AIMING,
254+
icon = Icons.Default.GpsFixed,
255+
labelResId = R.string.gyro_aiming,
256+
accentColor = PluviaTheme.colors.accentCyan,
257+
)
258+
)
249259
if (hasPhysicalController) {
250260
add(
251261
QuickMenuItem(

app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package app.gamenative.ui.component.dialog
22

33
import androidx.compose.foundation.layout.Row
4+
import androidx.compose.foundation.layout.padding
45
import androidx.compose.material.icons.Icons
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.unit.dp
8+
import kotlin.math.abs
59
import androidx.compose.material.icons.filled.Settings
610
import androidx.compose.material3.Icon
711
import androidx.compose.material3.IconButton
@@ -24,6 +28,8 @@ import com.alorma.compose.settings.ui.SettingsSwitch
2428
import com.alorma.compose.settings.ui.SettingsMenuLink
2529
import com.winlator.container.Container
2630

31+
private val GYRO_SENSITIVITY_VALUES = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f)
32+
2733
@Composable
2834
fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
2935
val config = state.config.value
@@ -98,6 +104,16 @@ fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
98104
state = config.shooterMode,
99105
onCheckedChange = { state.config.value = config.copy(shooterMode = it) },
100106
)
107+
SettingsListDropdown(
108+
colors = settingsTileColors(),
109+
title = { Text(text = stringResource(R.string.gyro_sensitivity)) },
110+
subtitle = { Text(text = stringResource(R.string.gyro_sensitivity_description)) },
111+
value = (GYRO_SENSITIVITY_VALUES.withIndex().minByOrNull { abs(it.value - config.gyroSensitivity) }?.index ?: 3).coerceIn(0, GYRO_SENSITIVITY_VALUES.lastIndex),
112+
items = GYRO_SENSITIVITY_VALUES.map { "${(it * 100).toInt()}%" },
113+
onItemSelected = { index ->
114+
state.config.value = config.copy(gyroSensitivity = GYRO_SENSITIVITY_VALUES[index])
115+
},
116+
)
101117
SettingsListDropdown(
102118
colors = settingsTileColors(),
103119
title = { Text(text = stringResource(R.string.external_display_input)) },

app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,19 @@ fun XServerScreen(
937937
false
938938
}
939939

940+
QuickMenuAction.GYRO_AIMING -> {
941+
val currentlyEnabled = container.getExtra("gyroAiming", "false") == "true"
942+
val newEnabled = !currentlyEnabled
943+
container.putExtra("gyroAiming", if (newEnabled) "true" else "false")
944+
container.saveData()
945+
PluviaApp.touchpadView?.setGyroAimingEnabled(newEnabled)
946+
PostHog.capture(
947+
event = "gyro_aiming_toggled",
948+
properties = mapOf("enabled" to newEnabled),
949+
)
950+
true
951+
}
952+
940953
QuickMenuAction.EXIT_GAME -> {
941954
PostHog.capture(
942955
event = "game_closed",
@@ -1343,6 +1356,8 @@ fun XServerScreen(
13431356
PluviaApp.touchpadView = TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true))
13441357
frameLayout.addView(PluviaApp.touchpadView)
13451358
PluviaApp.touchpadView?.setMoveCursorToTouchpoint(PrefManager.getBoolean("move_cursor_to_touchpoint", false))
1359+
PluviaApp.touchpadView?.setGyroSensitivity(container.getExtra("gyroSensitivity", "1").toFloatOrNull()?.coerceIn(0.25f, 2f) ?: 1f)
1360+
PluviaApp.touchpadView?.setGyroAimingEnabled(container.getExtra("gyroAiming", "false") == "true")
13461361

13471362
// Add invisible IME receiver to capture system keyboard input when keyboard is on external display
13481363
val imeDisplayContext = context.display?.let { display ->

app/src/main/java/app/gamenative/utils/ContainerUtils.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ object ContainerUtils {
317317
sharpnessEffect = container.getExtra("sharpnessEffect", "None"),
318318
sharpnessLevel = container.getExtra("sharpnessLevel", "100").toIntOrNull() ?: 100,
319319
sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toIntOrNull() ?: 100,
320+
gyroSensitivity = container.getExtra("gyroSensitivity", "1").toFloatOrNull()?.coerceIn(0.25f, 2f) ?: 1f,
320321
)
321322
}
322323

@@ -483,6 +484,7 @@ object ContainerUtils {
483484
container.putExtra("sharpnessEffect", containerData.sharpnessEffect)
484485
container.putExtra("sharpnessLevel", containerData.sharpnessLevel.toString())
485486
container.putExtra("sharpnessDenoise", containerData.sharpnessDenoise.toString())
487+
container.putExtra("gyroSensitivity", containerData.gyroSensitivity.coerceIn(0.25f, 2f).toString())
486488
try {
487489
container.language = containerData.language
488490
} catch (e: Exception) {

app/src/main/java/com/winlator/container/ContainerData.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ data class ContainerData(
9494
val sharpnessEffect: String = "None",
9595
val sharpnessLevel: Int = 100,
9696
val sharpnessDenoise: Int = 100,
97+
val gyroSensitivity: Float = 1f,
9798
) {
9899
companion object {
99100
val Saver = mapSaver(
@@ -157,6 +158,7 @@ data class ContainerData(
157158
"sharpnessEffect" to state.sharpnessEffect,
158159
"sharpnessLevel" to state.sharpnessLevel,
159160
"sharpnessDenoise" to state.sharpnessDenoise,
161+
"gyroSensitivity" to state.gyroSensitivity,
160162
)
161163
},
162164
restore = { savedMap ->
@@ -219,6 +221,7 @@ data class ContainerData(
219221
sharpnessEffect = (savedMap["sharpnessEffect"] as? String) ?: "None",
220222
sharpnessLevel = (savedMap["sharpnessLevel"] as? Int) ?: 100,
221223
sharpnessDenoise = (savedMap["sharpnessDenoise"] as? Int) ?: 100,
224+
gyroSensitivity = (savedMap["gyroSensitivity"] as? Float) ?: (savedMap["gyroSensitivity"] as? Double)?.toFloat() ?: 1f,
222225
)
223226
},
224227
)
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package com.winlator.widget;
2+
3+
import android.content.Context;
4+
import android.hardware.Sensor;
5+
import android.hardware.SensorEvent;
6+
import android.hardware.SensorEventListener;
7+
import android.hardware.SensorManager;
8+
import android.hardware.display.DisplayManager;
9+
import android.os.Handler;
10+
import android.os.Looper;
11+
import android.view.Display;
12+
import android.view.Surface;
13+
14+
import timber.log.Timber;
15+
16+
/**
17+
* Listens to the device gyroscope and converts angular velocity to relative mouse deltas
18+
* for aiming. Only active when started; use from the main thread.
19+
*/
20+
public class GyroAimingHelper implements SensorEventListener {
21+
public interface Listener {
22+
void onMouseDelta(int dx, int dy);
23+
}
24+
25+
private static final float DEFAULT_SENSITIVITY = 400f;
26+
private static final float MAX_DELTA_PER_FRAME = 120f;
27+
private static final int SENSOR_DELAY_US = 5_000; // 200 Hz
28+
29+
private final Context context;
30+
private final Listener listener;
31+
private final Handler mainHandler;
32+
private float sensitivity;
33+
34+
private SensorManager sensorManager;
35+
private DisplayManager displayManager;
36+
private Sensor gyro;
37+
private boolean running;
38+
private long lastTimestampNs = 0;
39+
private boolean hasLastTimestamp;
40+
/** Sub-pixel accumulation so small movements aren't lost when casting to int */
41+
private float accumDx = 0f;
42+
private float accumDy = 0f;
43+
44+
public GyroAimingHelper(Context context, Listener listener) {
45+
this(context, listener, DEFAULT_SENSITIVITY);
46+
}
47+
48+
public GyroAimingHelper(Context context, Listener listener, float sensitivity) {
49+
this.context = context.getApplicationContext();
50+
this.listener = listener;
51+
this.sensitivity = sensitivity > 0 ? sensitivity : DEFAULT_SENSITIVITY;
52+
this.mainHandler = new Handler(Looper.getMainLooper());
53+
}
54+
55+
public void start() {
56+
if (running) return;
57+
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
58+
displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
59+
if (sensorManager == null) {
60+
Timber.w("GyroAiming: SensorManager is null");
61+
return;
62+
}
63+
gyro = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
64+
if (gyro == null) {
65+
Timber.w("GyroAiming: no TYPE_GYROSCOPE sensor on this device");
66+
return;
67+
}
68+
hasLastTimestamp = false;
69+
accumDx = 0f;
70+
accumDy = 0f;
71+
boolean registered = sensorManager.registerListener(this, gyro, SENSOR_DELAY_US);
72+
if (!registered) {
73+
Timber.e("GyroAiming: failed to start, registerListener returned false (sensor=%s)", gyro.getName());
74+
return;
75+
}
76+
running = true;
77+
Timber.d("GyroAiming: started (sensor=%s)", gyro.getName());
78+
}
79+
80+
public void stop() {
81+
if (!running) return;
82+
running = false;
83+
mainHandler.removeCallbacksAndMessages(null);
84+
if (sensorManager != null && gyro != null) {
85+
sensorManager.unregisterListener(this, gyro);
86+
}
87+
sensorManager = null;
88+
displayManager = null;
89+
gyro = null;
90+
Timber.d("GyroAiming: stopped");
91+
}
92+
93+
public boolean isRunning() {
94+
return running;
95+
}
96+
97+
public void setSensitivity(float sensitivity) {
98+
this.sensitivity = sensitivity > 0 ? sensitivity : DEFAULT_SENSITIVITY;
99+
}
100+
101+
@Override
102+
public void onSensorChanged(SensorEvent event) {
103+
if (!running) return;
104+
if (event.sensor.getType() != Sensor.TYPE_GYROSCOPE || listener == null) return;
105+
106+
long t = event.timestamp;
107+
if (!hasLastTimestamp) {
108+
lastTimestampNs = t;
109+
hasLastTimestamp = true;
110+
return;
111+
}
112+
float dt = (t - lastTimestampNs) * 1e-9f;
113+
lastTimestampNs = t;
114+
if (dt <= 0 || dt > 0.5f) return; // skip invalid or huge gaps
115+
116+
float deviceRadX = event.values[0];
117+
float deviceRadY = event.values[1];
118+
119+
float radX;
120+
float radY;
121+
// Offset by +90deg to match aiming orientation.
122+
int effectiveRotation = (getDisplayRotation() + 1) & 0x3;
123+
switch (effectiveRotation) {
124+
case Surface.ROTATION_90:
125+
radX = deviceRadY;
126+
radY = -deviceRadX;
127+
break;
128+
case Surface.ROTATION_180:
129+
radX = -deviceRadX;
130+
radY = -deviceRadY;
131+
break;
132+
case Surface.ROTATION_270:
133+
radX = -deviceRadY;
134+
radY = deviceRadX;
135+
break;
136+
case Surface.ROTATION_0:
137+
default:
138+
radX = deviceRadX;
139+
radY = deviceRadY;
140+
break;
141+
}
142+
143+
float dx = -radX * dt * sensitivity;
144+
float dy = radY * dt * sensitivity;
145+
146+
// Clamp per-frame delta for stability
147+
dx = clamp(dx, -MAX_DELTA_PER_FRAME, MAX_DELTA_PER_FRAME);
148+
dy = clamp(dy, -MAX_DELTA_PER_FRAME, MAX_DELTA_PER_FRAME);
149+
150+
accumDx += dx;
151+
accumDy += dy;
152+
int ix = (int) accumDx;
153+
int iy = (int) accumDy;
154+
if (ix != 0 || iy != 0) {
155+
accumDx -= ix;
156+
accumDy -= iy;
157+
final int fix = ix;
158+
final int fiy = iy;
159+
mainHandler.post(() -> {
160+
if (!running) return;
161+
listener.onMouseDelta(fix, fiy);
162+
});
163+
}
164+
}
165+
166+
@Override
167+
public void onAccuracyChanged(Sensor sensor, int accuracy) {}
168+
169+
private int getDisplayRotation() {
170+
if (displayManager == null) return Surface.ROTATION_0;
171+
Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
172+
if (display == null) return Surface.ROTATION_0;
173+
return display.getRotation();
174+
}
175+
176+
private static float clamp(float v, float lo, float hi) {
177+
return v < lo ? lo : (v > hi ? hi : v);
178+
}
179+
}

app/src/main/java/com/winlator/widget/TouchpadView.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import com.winlator.xserver.XKeycode;
2828
import com.winlator.xserver.XServer;
2929

30+
import timber.log.Timber;
31+
3032
public class TouchpadView extends View implements View.OnCapturedPointerListener {
3133
private static final byte MAX_FINGERS = 4;
3234
private static final short MAX_TWO_FINGERS_SCROLL_DISTANCE = 350;
@@ -107,6 +109,12 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener
107109
// Accumulated deltas for deciding which two-finger gesture to lock into
108110
private float accumulatedPinchDelta, accumulatedPanDelta;
109111

112+
// Gyro aiming: device rotation drives relative mouse movement
113+
private static final float GYRO_BASE_SENSITIVITY = 400f;
114+
private GyroAimingHelper gyroAimingHelper;
115+
private boolean gyroAimingEnabled;
116+
private float gyroSensitivity = 1f;
117+
110118
public TouchpadView(Context context, XServer xServer, boolean capturePointerOnExternalMouse) {
111119
super(context);
112120
this.capturePointerOnExternalMouse = capturePointerOnExternalMouse;
@@ -139,6 +147,12 @@ public TouchpadView(Context context, XServer xServer, boolean capturePointerOnEx
139147
}
140148
}
141149

150+
@Override
151+
protected void onDetachedFromWindow() {
152+
super.onDetachedFromWindow();
153+
setGyroAimingEnabled(false);
154+
}
155+
142156
@Override
143157
public void onWindowFocusChanged(boolean hasFocus) {
144158
super.onWindowFocusChanged(hasFocus);
@@ -1276,4 +1290,42 @@ public TouchGestureConfig getGestureConfig() {
12761290
public void setTouchscreenMouseDisabled(boolean disabled) {
12771291
this.touchscreenMouseDisabled = disabled;
12781292
}
1293+
1294+
public void setGyroAimingEnabled(boolean enabled) {
1295+
if (gyroAimingEnabled == enabled) return;
1296+
gyroAimingEnabled = enabled;
1297+
Timber.d("GyroAiming: setGyroAimingEnabled(%s)", enabled);
1298+
if (enabled) {
1299+
if (gyroAimingHelper == null) {
1300+
float sens = GYRO_BASE_SENSITIVITY * gyroSensitivity;
1301+
gyroAimingHelper = new GyroAimingHelper(getContext(), (dx, dy) -> {
1302+
if (!isEnabled()) return;
1303+
if (!gyroAimingEnabled) return;
1304+
if (xServer.isRelativeMouseMovement()) {
1305+
xServer.getWinHandler().mouseEvent(MouseEventFlags.MOVE, dx, dy, 0);
1306+
} else {
1307+
xServer.injectPointerMoveDelta(dx, dy);
1308+
}
1309+
}, sens);
1310+
} else {
1311+
gyroAimingHelper.setSensitivity(GYRO_BASE_SENSITIVITY * gyroSensitivity);
1312+
}
1313+
gyroAimingHelper.start();
1314+
} else {
1315+
if (gyroAimingHelper != null) {
1316+
gyroAimingHelper.stop();
1317+
}
1318+
}
1319+
}
1320+
1321+
public void setGyroSensitivity(float sensitivity) {
1322+
gyroSensitivity = sensitivity > 0 ? sensitivity : 1f;
1323+
if (gyroAimingHelper != null) {
1324+
gyroAimingHelper.setSensitivity(GYRO_BASE_SENSITIVITY * gyroSensitivity);
1325+
}
1326+
}
1327+
1328+
public boolean isGyroAimingEnabled() {
1329+
return gyroAimingEnabled;
1330+
}
12791331
}

0 commit comments

Comments
 (0)