feat(input-manager): add haptic feedback for game launch with gamepad
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
@ -1,8 +1,8 @@
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from typing import Protocol, cast
|
from typing import Protocol, cast
|
||||||
from evdev import InputDevice, ecodes, list_devices
|
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
|
||||||
import pyudev
|
from pyudev import Context, Monitor, MonitorObserver, Device
|
||||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
|
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
|
||||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||||
from PySide6.QtGui import QKeyEvent
|
from PySide6.QtGui import QKeyEvent
|
||||||
@ -52,8 +52,7 @@ class InputManager(QObject):
|
|||||||
"""
|
"""
|
||||||
Manages input from gamepads and keyboards for navigating the application interface.
|
Manages input from gamepads and keyboards for navigating the application interface.
|
||||||
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
|
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
|
||||||
for seamless UI interaction. Enables fullscreen mode when a gamepad is connected
|
for seamless UI interaction.
|
||||||
and restores normal mode when disconnected.
|
|
||||||
"""
|
"""
|
||||||
# Signals for gamepad events
|
# Signals for gamepad events
|
||||||
button_pressed = Signal(int) # Signal for button presses
|
button_pressed = Signal(int) # Signal for button presses
|
||||||
@ -83,6 +82,7 @@ class InputManager(QObject):
|
|||||||
self.gamepad_thread: threading.Thread | None = None
|
self.gamepad_thread: threading.Thread | None = None
|
||||||
self.running = True
|
self.running = True
|
||||||
self._is_fullscreen = read_fullscreen_config()
|
self._is_fullscreen = read_fullscreen_config()
|
||||||
|
self.rumble_effect_id: int | None = None # Store the rumble effect ID
|
||||||
|
|
||||||
# Add variables for continuous D-pad movement
|
# Add variables for continuous D-pad movement
|
||||||
self.dpad_timer = QTimer(self)
|
self.dpad_timer = QTimer(self)
|
||||||
@ -126,6 +126,46 @@ class InputManager(QObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
|
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def trigger_rumble(self, duration_ms: int = 200, strong_magnitude: int = 0x8000, weak_magnitude: int = 0x8000) -> None:
|
||||||
|
"""Trigger a rumble effect on the gamepad if supported."""
|
||||||
|
if not self.gamepad:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Check if the gamepad supports force feedback
|
||||||
|
caps = self.gamepad.capabilities()
|
||||||
|
if ecodes.EV_FF not in caps or ecodes.FF_RUMBLE not in caps.get(ecodes.EV_FF, []):
|
||||||
|
logger.debug("Gamepad does not support force feedback or rumble")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a rumble effect
|
||||||
|
rumble = ff.Rumble(strong_magnitude=strong_magnitude, weak_magnitude=weak_magnitude)
|
||||||
|
effect = ff.Effect(
|
||||||
|
id=-1, # Let evdev assign an ID
|
||||||
|
type=ecodes.FF_RUMBLE,
|
||||||
|
direction=0, # Direction (not used for rumble)
|
||||||
|
replay=ff.Replay(length=duration_ms, delay=0),
|
||||||
|
u=ff.EffectType(ff_rumble_effect=rumble)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload the effect
|
||||||
|
self.rumble_effect_id = self.gamepad.upload_effect(effect)
|
||||||
|
# Play the effect
|
||||||
|
event = InputEvent(0, 0, ecodes.EV_FF, self.rumble_effect_id, 1)
|
||||||
|
self.gamepad.write_event(event)
|
||||||
|
# Schedule effect erasure after duration
|
||||||
|
QTimer.singleShot(duration_ms, self.stop_rumble)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering rumble: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def stop_rumble(self) -> None:
|
||||||
|
"""Stop the rumble effect and clean up."""
|
||||||
|
if self.gamepad and self.rumble_effect_id is not None:
|
||||||
|
try:
|
||||||
|
self.gamepad.erase_effect(self.rumble_effect_id)
|
||||||
|
self.rumble_effect_id = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping rumble: {e}", exc_info=True)
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
def handle_button_slot(self, button_code: int) -> None:
|
def handle_button_slot(self, button_code: int) -> None:
|
||||||
try:
|
try:
|
||||||
@ -222,6 +262,7 @@ class InputManager(QObject):
|
|||||||
# Game launch on detail page
|
# Game launch on detail page
|
||||||
if (button_code in BUTTONS['confirm']) and self._parent.currentDetailPage is not None and self._parent.current_add_game_dialog is None:
|
if (button_code in BUTTONS['confirm']) and self._parent.currentDetailPage is not None and self._parent.current_add_game_dialog is None:
|
||||||
if self._parent.current_exec_line:
|
if self._parent.current_exec_line:
|
||||||
|
self.trigger_rumble()
|
||||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -728,17 +769,17 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
def run_udev_monitor(self) -> None:
|
def run_udev_monitor(self) -> None:
|
||||||
try:
|
try:
|
||||||
context = pyudev.Context()
|
context = Context()
|
||||||
monitor = pyudev.Monitor.from_netlink(context)
|
monitor = Monitor.from_netlink(context)
|
||||||
monitor.filter_by(subsystem='input')
|
monitor.filter_by(subsystem='input')
|
||||||
observer = pyudev.MonitorObserver(monitor, self.handle_udev_event)
|
observer = MonitorObserver(monitor, self.handle_udev_event)
|
||||||
observer.start()
|
observer.start()
|
||||||
while self.running:
|
while self.running:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in udev monitor: {e}", exc_info=True)
|
logger.error(f"Error in udev monitor: {e}", exc_info=True)
|
||||||
|
|
||||||
def handle_udev_event(self, action: str, device: pyudev.Device) -> None:
|
def handle_udev_event(self, action: str, device: Device) -> None:
|
||||||
try:
|
try:
|
||||||
if action == 'add':
|
if action == 'add':
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@ -746,6 +787,7 @@ class InputManager(QObject):
|
|||||||
elif action == 'remove' and self.gamepad:
|
elif action == 'remove' and self.gamepad:
|
||||||
if not any(self.gamepad.path == path for path in list_devices()):
|
if not any(self.gamepad.path == path for path in list_devices()):
|
||||||
logger.info("Gamepad disconnected")
|
logger.info("Gamepad disconnected")
|
||||||
|
self.stop_rumble()
|
||||||
self.gamepad = None
|
self.gamepad = None
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
@ -759,6 +801,7 @@ class InputManager(QObject):
|
|||||||
new_gamepad = self.find_gamepad()
|
new_gamepad = self.find_gamepad()
|
||||||
if new_gamepad and new_gamepad != self.gamepad:
|
if new_gamepad and new_gamepad != self.gamepad:
|
||||||
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
||||||
|
self.stop_rumble()
|
||||||
self.gamepad = new_gamepad
|
self.gamepad = new_gamepad
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
@ -811,6 +854,7 @@ class InputManager(QObject):
|
|||||||
finally:
|
finally:
|
||||||
if self.gamepad:
|
if self.gamepad:
|
||||||
try:
|
try:
|
||||||
|
self.stop_rumble()
|
||||||
self.gamepad.close()
|
self.gamepad.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -820,6 +864,7 @@ class InputManager(QObject):
|
|||||||
try:
|
try:
|
||||||
self.running = False
|
self.running = False
|
||||||
self.dpad_timer.stop()
|
self.dpad_timer.stop()
|
||||||
|
self.stop_rumble()
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
if self.gamepad:
|
if self.gamepad:
|
||||||
|
Reference in New Issue
Block a user