Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.QueryStats
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
Expand Down Expand Up @@ -91,6 +92,7 @@ object QuickMenuAction {
const val EDIT_CONTROLS = 4
const val EDIT_PHYSICAL_CONTROLLER = 5
const val PERFORMANCE_HUD = 6
const val GYRO_AIMING = 7
}

private object QuickMenuTab {
Expand Down Expand Up @@ -247,6 +249,14 @@ fun QuickMenu(
accentColor = PluviaTheme.colors.accentPurple,
)
)
add(
QuickMenuItem(
id = QuickMenuAction.GYRO_AIMING,
icon = Icons.Default.GpsFixed,
labelResId = R.string.gyro_aiming,
accentColor = PluviaTheme.colors.accentCyan,
)
)
if (hasPhysicalController) {
add(
QuickMenuItem(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package app.gamenative.ui.component.dialog

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand All @@ -24,6 +28,8 @@ import com.alorma.compose.settings.ui.SettingsSwitch
import com.alorma.compose.settings.ui.SettingsMenuLink
import com.winlator.container.Container

private val GYRO_SENSITIVITY_VALUES = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f)

@Composable
fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
val config = state.config.value
Expand Down Expand Up @@ -98,6 +104,16 @@ fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
state = config.shooterMode,
onCheckedChange = { state.config.value = config.copy(shooterMode = it) },
)
SettingsListDropdown(
colors = settingsTileColors(),
title = { Text(text = stringResource(R.string.gyro_sensitivity)) },
subtitle = { Text(text = stringResource(R.string.gyro_sensitivity_description)) },
value = (GYRO_SENSITIVITY_VALUES.withIndex().minByOrNull { abs(it.value - config.gyroSensitivity) }?.index ?: 3).coerceIn(0, GYRO_SENSITIVITY_VALUES.lastIndex),
items = GYRO_SENSITIVITY_VALUES.map { "${(it * 100).toInt()}%" },
onItemSelected = { index ->
state.config.value = config.copy(gyroSensitivity = GYRO_SENSITIVITY_VALUES[index])
},
)
SettingsListDropdown(
colors = settingsTileColors(),
title = { Text(text = stringResource(R.string.external_display_input)) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,19 @@ fun XServerScreen(
false
}

QuickMenuAction.GYRO_AIMING -> {
val currentlyEnabled = container.getExtra("gyroAiming", "false") == "true"
val newEnabled = !currentlyEnabled
container.putExtra("gyroAiming", if (newEnabled) "true" else "false")
container.saveData()
PluviaApp.touchpadView?.setGyroAimingEnabled(newEnabled)
PostHog.capture(
event = "gyro_aiming_toggled",
properties = mapOf("enabled" to newEnabled),
)
true
}

QuickMenuAction.EXIT_GAME -> {
PostHog.capture(
event = "game_closed",
Expand Down Expand Up @@ -1367,6 +1380,8 @@ fun XServerScreen(
PluviaApp.touchpadView = TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true))
frameLayout.addView(PluviaApp.touchpadView)
PluviaApp.touchpadView?.setMoveCursorToTouchpoint(PrefManager.getBoolean("move_cursor_to_touchpoint", false))
PluviaApp.touchpadView?.setGyroSensitivity(container.getExtra("gyroSensitivity", "1").toFloatOrNull()?.coerceIn(0.25f, 2f) ?: 1f)
PluviaApp.touchpadView?.setGyroAimingEnabled(container.getExtra("gyroAiming", "false") == "true")

// Add invisible IME receiver to capture system keyboard input when keyboard is on external display
val imeDisplayContext = context.display?.let { display ->
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ object ContainerUtils {
sharpnessEffect = container.getExtra("sharpnessEffect", "None"),
sharpnessLevel = container.getExtra("sharpnessLevel", "100").toIntOrNull() ?: 100,
sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toIntOrNull() ?: 100,
gyroSensitivity = container.getExtra("gyroSensitivity", "1").toFloatOrNull()?.coerceIn(0.25f, 2f) ?: 1f,
)
}

Expand Down Expand Up @@ -483,6 +484,7 @@ object ContainerUtils {
container.putExtra("sharpnessEffect", containerData.sharpnessEffect)
container.putExtra("sharpnessLevel", containerData.sharpnessLevel.toString())
container.putExtra("sharpnessDenoise", containerData.sharpnessDenoise.toString())
container.putExtra("gyroSensitivity", containerData.gyroSensitivity.coerceIn(0.25f, 2f).toString())
try {
container.language = containerData.language
} catch (e: Exception) {
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/winlator/container/ContainerData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ data class ContainerData(
val sharpnessEffect: String = "None",
val sharpnessLevel: Int = 100,
val sharpnessDenoise: Int = 100,
val gyroSensitivity: Float = 1f,
) {
companion object {
val Saver = mapSaver(
Expand Down Expand Up @@ -157,6 +158,7 @@ data class ContainerData(
"sharpnessEffect" to state.sharpnessEffect,
"sharpnessLevel" to state.sharpnessLevel,
"sharpnessDenoise" to state.sharpnessDenoise,
"gyroSensitivity" to state.gyroSensitivity,
)
},
restore = { savedMap ->
Expand Down Expand Up @@ -219,6 +221,7 @@ data class ContainerData(
sharpnessEffect = (savedMap["sharpnessEffect"] as? String) ?: "None",
sharpnessLevel = (savedMap["sharpnessLevel"] as? Int) ?: 100,
sharpnessDenoise = (savedMap["sharpnessDenoise"] as? Int) ?: 100,
gyroSensitivity = (savedMap["gyroSensitivity"] as? Float) ?: (savedMap["gyroSensitivity"] as? Double)?.toFloat() ?: 1f,
)
},
)
Expand Down
179 changes: 179 additions & 0 deletions app/src/main/java/com/winlator/widget/GyroAimingHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package com.winlator.widget;

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.hardware.display.DisplayManager;
import android.os.Handler;
import android.os.Looper;
import android.view.Display;
import android.view.Surface;

import timber.log.Timber;

/**
* Listens to the device gyroscope and converts angular velocity to relative mouse deltas
* for aiming. Only active when started; use from the main thread.
*/
public class GyroAimingHelper implements SensorEventListener {
public interface Listener {
void onMouseDelta(int dx, int dy);
}

private static final float DEFAULT_SENSITIVITY = 400f;
private static final float MAX_DELTA_PER_FRAME = 120f;
private static final int SENSOR_DELAY_US = 5_000; // 200 Hz

private final Context context;
private final Listener listener;
private final Handler mainHandler;
private float sensitivity;

private SensorManager sensorManager;
private DisplayManager displayManager;
private Sensor gyro;
private boolean running;
private long lastTimestampNs = 0;
private boolean hasLastTimestamp;
/** Sub-pixel accumulation so small movements aren't lost when casting to int */
private float accumDx = 0f;
private float accumDy = 0f;

public GyroAimingHelper(Context context, Listener listener) {
this(context, listener, DEFAULT_SENSITIVITY);
}

public GyroAimingHelper(Context context, Listener listener, float sensitivity) {
this.context = context.getApplicationContext();
this.listener = listener;
this.sensitivity = sensitivity > 0 ? sensitivity : DEFAULT_SENSITIVITY;
this.mainHandler = new Handler(Looper.getMainLooper());
}

public void start() {
if (running) return;
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
if (sensorManager == null) {
Timber.w("GyroAiming: SensorManager is null");
return;
}
gyro = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
if (gyro == null) {
Timber.w("GyroAiming: no TYPE_GYROSCOPE sensor on this device");
return;
}
hasLastTimestamp = false;
accumDx = 0f;
accumDy = 0f;
boolean registered = sensorManager.registerListener(this, gyro, SENSOR_DELAY_US);
if (!registered) {
Timber.e("GyroAiming: failed to start, registerListener returned false (sensor=%s)", gyro.getName());
return;
}
running = true;
Timber.d("GyroAiming: started (sensor=%s)", gyro.getName());
}

public void stop() {
if (!running) return;
running = false;
mainHandler.removeCallbacksAndMessages(null);
if (sensorManager != null && gyro != null) {
sensorManager.unregisterListener(this, gyro);
}
sensorManager = null;
displayManager = null;
gyro = null;
Timber.d("GyroAiming: stopped");
}

public boolean isRunning() {
return running;
}

public void setSensitivity(float sensitivity) {
this.sensitivity = sensitivity > 0 ? sensitivity : DEFAULT_SENSITIVITY;
}

@Override
public void onSensorChanged(SensorEvent event) {
if (!running) return;
if (event.sensor.getType() != Sensor.TYPE_GYROSCOPE || listener == null) return;

long t = event.timestamp;
if (!hasLastTimestamp) {
lastTimestampNs = t;
hasLastTimestamp = true;
return;
}
float dt = (t - lastTimestampNs) * 1e-9f;
lastTimestampNs = t;
if (dt <= 0 || dt > 0.5f) return; // skip invalid or huge gaps

float deviceRadX = event.values[0];
float deviceRadY = event.values[1];

float radX;
float radY;
// Offset by +90deg to match aiming orientation.
int effectiveRotation = (getDisplayRotation() + 1) & 0x3;
switch (effectiveRotation) {
case Surface.ROTATION_90:
radX = deviceRadY;
radY = -deviceRadX;
break;
case Surface.ROTATION_180:
radX = -deviceRadX;
radY = -deviceRadY;
break;
case Surface.ROTATION_270:
radX = -deviceRadY;
radY = deviceRadX;
break;
case Surface.ROTATION_0:
default:
radX = deviceRadX;
radY = deviceRadY;
break;
}

float dx = -radX * dt * sensitivity;
float dy = radY * dt * sensitivity;

// Clamp per-frame delta for stability
dx = clamp(dx, -MAX_DELTA_PER_FRAME, MAX_DELTA_PER_FRAME);
dy = clamp(dy, -MAX_DELTA_PER_FRAME, MAX_DELTA_PER_FRAME);

accumDx += dx;
accumDy += dy;
int ix = (int) accumDx;
int iy = (int) accumDy;
if (ix != 0 || iy != 0) {
accumDx -= ix;
accumDy -= iy;
final int fix = ix;
final int fiy = iy;
mainHandler.post(() -> {
if (!running) return;
listener.onMouseDelta(fix, fiy);
});
}
}

@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {}

private int getDisplayRotation() {
if (displayManager == null) return Surface.ROTATION_0;
Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
if (display == null) return Surface.ROTATION_0;
return display.getRotation();
}

private static float clamp(float v, float lo, float hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
}
52 changes: 52 additions & 0 deletions app/src/main/java/com/winlator/widget/TouchpadView.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import com.winlator.xserver.XKeycode;
import com.winlator.xserver.XServer;

import timber.log.Timber;

public class TouchpadView extends View implements View.OnCapturedPointerListener {
private static final byte MAX_FINGERS = 4;
private static final short MAX_TWO_FINGERS_SCROLL_DISTANCE = 350;
Expand Down Expand Up @@ -111,6 +113,12 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener
// Accumulated deltas for deciding which two-finger gesture to lock into
private float accumulatedPinchDelta, accumulatedPanDelta;

// Gyro aiming: device rotation drives relative mouse movement
private static final float GYRO_BASE_SENSITIVITY = 400f;
private GyroAimingHelper gyroAimingHelper;
private boolean gyroAimingEnabled;
private float gyroSensitivity = 1f;

public TouchpadView(Context context, XServer xServer, boolean capturePointerOnExternalMouse) {
super(context);
this.capturePointerOnExternalMouse = capturePointerOnExternalMouse;
Expand Down Expand Up @@ -143,6 +151,12 @@ public TouchpadView(Context context, XServer xServer, boolean capturePointerOnEx
}
}

@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
setGyroAimingEnabled(false);
}

@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
Expand Down Expand Up @@ -1301,4 +1315,42 @@ public TouchGestureConfig getGestureConfig() {
public void setTouchscreenMouseDisabled(boolean disabled) {
this.touchscreenMouseDisabled = disabled;
}

public void setGyroAimingEnabled(boolean enabled) {
if (gyroAimingEnabled == enabled) return;
gyroAimingEnabled = enabled;
Timber.d("GyroAiming: setGyroAimingEnabled(%s)", enabled);
if (enabled) {
if (gyroAimingHelper == null) {
float sens = GYRO_BASE_SENSITIVITY * gyroSensitivity;
gyroAimingHelper = new GyroAimingHelper(getContext(), (dx, dy) -> {
if (!isEnabled()) return;
if (!gyroAimingEnabled) return;
if (xServer.isRelativeMouseMovement()) {
xServer.getWinHandler().mouseEvent(MouseEventFlags.MOVE, dx, dy, 0);
} else {
xServer.injectPointerMoveDelta(dx, dy);
}
}, sens);
} else {
gyroAimingHelper.setSensitivity(GYRO_BASE_SENSITIVITY * gyroSensitivity);
}
gyroAimingHelper.start();
} else {
if (gyroAimingHelper != null) {
gyroAimingHelper.stop();
}
}
}

public void setGyroSensitivity(float sensitivity) {
gyroSensitivity = sensitivity > 0 ? sensitivity : 1f;
if (gyroAimingHelper != null) {
gyroAimingHelper.setSensitivity(GYRO_BASE_SENSITIVITY * gyroSensitivity);
}
}

public boolean isGyroAimingEnabled() {
return gyroAimingEnabled;
}
}
Loading
Loading