diff --git a/controller_helper.py b/controller_helper.py new file mode 100644 index 0000000..99b47de --- /dev/null +++ b/controller_helper.py @@ -0,0 +1,684 @@ +#!/usr/bin/env python3 + +import argparse +import glob +import json +import os +import selectors +import signal +import sys +from pathlib import Path +from typing import Optional + +try: + import evdev + from evdev import ecodes +except Exception: + evdev = None + ecodes = None + + +CONTROLLER_KEYWORDS = ( + "controller", + "gamepad", + "joystick", + "xbox", + "dualsense", + "dualshock", + "wireless controller", + "8bitdo", + "pro controller", + "switch pro", + "joycon", + "joy-con", + "steam controller", +) + +# VID → controller family. Takes priority over name-based detection. +VENDOR_FAMILY_MAP = { + "054c": "playstation", # Sony Interactive Entertainment + "045e": "xbox", # Microsoft Corporation + "057e": "nintendo", # Nintendo Co., Ltd + "28de": "steam", # Valve Corporation + "2dc8": "8bitdo", # 8BitDo Technology + "0f0d": "generic", # Hori Co., Ltd + "0079": "generic", # DragonRise Inc. (generic USB gamepads) + "0810": "generic", # Personal Communication Systems + "046d": "generic", # Logitech +} + +# Sony PID → specific family (dualsense vs dualshock vs generic playstation) +SONY_PRODUCT_FAMILIES = { + "0ce6": "dualsense", # DualSense (PS5) + "0df2": "dualsense", # DualSense Edge + "0e5f": "dualsense", # DualSense Access Controller + "09cc": "dualshock", # DualShock 4 (CUH-ZCT2, 2nd gen) + "05c4": "dualshock", # DualShock 4 (CUH-ZCT1, 1st gen) + "0ba0": "dualshock", # DualShock 4 USB Wireless Adaptor +} + +DEFAULT_CAPABILITIES = { + "buttons": [ + "BTN_SOUTH", + "BTN_EAST", + "BTN_NORTH", + "BTN_WEST", + "BTN_TL", + "BTN_TR", + "BTN_SELECT", + "BTN_START", + "BTN_MODE", + "BTN_THUMBL", + "BTN_THUMBR", + "BTN_DPAD_UP", + "BTN_DPAD_DOWN", + "BTN_DPAD_LEFT", + "BTN_DPAD_RIGHT", + ], + "axes": [ + "ABS_X", + "ABS_Y", + "ABS_RX", + "ABS_RY", + "ABS_Z", + "ABS_RZ", + "ABS_HAT0X", + "ABS_HAT0Y", + ], +} + +RUNNING = True + +TARGET_DEVICE_CONFIGS = { + "xinput": { + "name": "Aegis Virtual Xbox Controller", + "vendor": 0x045E, + "product": 0x028E, + "version": 0x0110, + "bustype": 0x03, + }, + "dualsense": { + "name": "Sony Interactive Entertainment DualSense Wireless Controller", + "vendor": 0x054C, + "product": 0x0CE6, + "version": 0x8111, + "bustype": 0x05, + }, +} + + +def classify_family(name: str, vendor_id: str = "0000", product_id: str = "0000") -> str: + # VID/PID-based detection takes priority over name matching. + vid = (vendor_id or "0000").lower().zfill(4) + pid = (product_id or "0000").lower().zfill(4) + + if vid in VENDOR_FAMILY_MAP: + base_family = VENDOR_FAMILY_MAP[vid] + if base_family == "playstation": + return SONY_PRODUCT_FAMILIES.get(pid, "playstation") + return base_family + + # Fall back to name-based detection for unrecognised vendors. + value = (name or "").lower() + if "dualsense" in value or "ps5" in value: + return "dualsense" + if "dualshock" in value or "wireless controller" in value or "ps4" in value: + return "dualshock" + if "xbox" in value: + return "xbox" + if "8bitdo" in value: + return "8bitdo" + if ( + "switch" in value + or "joycon" in value + or "joy-con" in value + or "pro controller" in value + ): + return "nintendo" + if "steam controller" in value or ("valve" in value and "controller" in value): + return "steam" + if any(keyword in value for keyword in CONTROLLER_KEYWORDS): + return "generic" + return "unknown" + + +def infer_connection_type(sysfs_root: Path) -> str: + try: + path_str = str(sysfs_root.resolve()) + except Exception: + path_str = str(sysfs_root) + + lowered = path_str.lower() + if "bluetooth" in lowered: + return "bluetooth" + if "usb" in lowered: + return "usb" + return "unknown" + + +def detect_capabilities(name: str, evdev_caps: Optional[dict] = None) -> list: + family = classify_family(name) + capabilities = ["buttons", "sticks"] + + if evdev_caps is not None and ecodes is not None: + abs_codes = _normalize_abs_codes(evdev_caps.get(ecodes.EV_ABS, [])) + if ecodes.ABS_Z in abs_codes or ecodes.ABS_RZ in abs_codes: + capabilities.append("triggers") + ff_effects = evdev_caps.get(ecodes.EV_FF, []) + if ff_effects: + capabilities.append("rumble") + else: + # Nintendo Pro Controller has digital shoulder buttons, no analog triggers. + if family not in {"nintendo"}: + capabilities.append("triggers") + + # Gyro and touchpad are inferred from controller family since evdev exposes + # them on a separate input node that isn't the gamepad interface. + if family in {"dualsense", "dualshock"}: + capabilities.extend(["gyro", "touchpad"]) + elif family in {"nintendo", "steam"}: + capabilities.append("gyro") + + return sorted(set(capabilities)) + + +def _normalize_abs_codes(raw_abs_codes): + normalized = set() + for code in raw_abs_codes: + if isinstance(code, tuple): + normalized.add(code[0]) + else: + normalized.add(code) + return normalized + + +def _looks_like_gamepad(device) -> bool: + if evdev is None or ecodes is None: + return False + + capabilities = device.capabilities() + key_codes = set(capabilities.get(ecodes.EV_KEY, [])) + abs_codes = _normalize_abs_codes(capabilities.get(ecodes.EV_ABS, [])) + + has_gamepad_buttons = any( + code in key_codes + for code in ( + ecodes.BTN_SOUTH, + ecodes.BTN_EAST, + ecodes.BTN_NORTH, + ecodes.BTN_WEST, + ecodes.BTN_TL, + ecodes.BTN_TR, + ecodes.BTN_SELECT, + ecodes.BTN_START, + ecodes.BTN_MODE, + ecodes.BTN_THUMBL, + ecodes.BTN_THUMBR, + ) + ) + has_gamepad_axes = any( + code in abs_codes + for code in ( + ecodes.ABS_X, + ecodes.ABS_Y, + ecodes.ABS_RX, + ecodes.ABS_RY, + ecodes.ABS_HAT0X, + ecodes.ABS_HAT0Y, + ) + ) + + return has_gamepad_buttons and has_gamepad_axes + + +def _controller_from_evdev(device): + event_name = os.path.basename(device.path) + event_root = Path("/sys/class/input") / event_name / "device" + vendor_id = "0000" + product_id = "0000" + + try: + vendor_id = (event_root / "id" / "vendor").read_text(encoding="utf8").strip() + product_id = (event_root / "id" / "product").read_text(encoding="utf8").strip() + except Exception: + pass + + evdev_caps = device.capabilities() + family = classify_family(device.name, vendor_id, product_id) + + return { + "id": event_name, + "name": device.name, + "vendorId": vendor_id.lower(), + "productId": product_id.lower(), + "connectionType": infer_connection_type(event_root), + "family": family, + "capabilities": detect_capabilities(device.name, evdev_caps), + "eventPath": device.path, + } + + +def enumerate_input_candidates(): + controllers = [] + + if evdev is not None: + for device_path in evdev.list_devices(): + try: + device = evdev.InputDevice(device_path) + except Exception: + continue + + try: + if _looks_like_gamepad(device): + controllers.append(_controller_from_evdev(device)) + finally: + device.close() + + if controllers: + for index, controller in enumerate(controllers): + controller["isPrimary"] = index == 0 + return controllers + + for event_path in sorted(glob.glob("/sys/class/input/event*")): + event_root = Path(event_path) + device_name_path = event_root / "device" / "name" + if not device_name_path.exists(): + continue + + try: + name = device_name_path.read_text(encoding="utf8").strip() + except Exception: + continue + + lowered = name.lower() + if not any(keyword in lowered for keyword in CONTROLLER_KEYWORDS): + continue + + event_name = event_root.name + vendor_id = "0000" + product_id = "0000" + + try: + vendor_id = (event_root / "device" / "id" / "vendor").read_text(encoding="utf8").strip() + product_id = (event_root / "device" / "id" / "product").read_text(encoding="utf8").strip() + except Exception: + pass + + controllers.append( + { + "id": event_name, + "name": name, + "vendorId": vendor_id.lower(), + "productId": product_id.lower(), + "connectionType": infer_connection_type(event_root / "device"), + "family": classify_family(name, vendor_id, product_id), + "capabilities": detect_capabilities(name), + "eventPath": f"/dev/input/{event_name}", + } + ) + + for index, controller in enumerate(controllers): + controller["isPrimary"] = index == 0 + + return controllers + + +def build_probe(): + uinput_path = "/dev/uinput" + uinput_access = os.access(uinput_path, os.R_OK | os.W_OK) + return { + "python_ok": True, + "evdev_available": evdev is not None, + "uinput_present": os.path.exists(uinput_path), + "uinput_access": uinput_access, + "uinput_path": uinput_path, + "running_in_container": os.path.exists("/.dockerenv") + or os.path.exists("/run/.containerenv"), + "controller_count": len(enumerate_input_candidates()), + } + + +def create_virtual_capabilities(): + if evdev is None: + return None + + button_codes = [getattr(ecodes, code) for code in DEFAULT_CAPABILITIES["buttons"]] + axis_codes = [ + ( + getattr(ecodes, code), + evdev.AbsInfo(value=0, min=-32768, max=32767, fuzz=16, flat=128, resolution=0), + ) + for code in DEFAULT_CAPABILITIES["axes"][:4] + ] + trigger_codes = [ + ( + getattr(ecodes, code), + evdev.AbsInfo(value=0, min=0, max=255, fuzz=0, flat=0, resolution=0), + ) + for code in DEFAULT_CAPABILITIES["axes"][4:6] + ] + hat_codes = [ + ( + getattr(ecodes, code), + evdev.AbsInfo(value=0, min=-1, max=1, fuzz=0, flat=0, resolution=0), + ) + for code in DEFAULT_CAPABILITIES["axes"][6:] + ] + + return { + ecodes.EV_KEY: button_codes, + ecodes.EV_ABS: axis_codes + trigger_codes + hat_codes, + } + +def _scale_value(value, source_min, source_max, target_min, target_max): + if source_max == source_min: + return target_min + + ratio = (value - source_min) / (source_max - source_min) + scaled = target_min + ratio * (target_max - target_min) + return int(round(scaled)) + + +def _source_abs_info(device, code): + try: + return device.absinfo(code) + except Exception: + return None + + +def _target_abs_range(code): + if code in ( + ecodes.ABS_X, + ecodes.ABS_Y, + ecodes.ABS_RX, + ecodes.ABS_RY, + ): + return (-32768, 32767) + if code in (ecodes.ABS_Z, ecodes.ABS_RZ): + return (0, 255) + if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): + return (-1, 1) + return None + + +def _clamp(value, lower, upper): + return max(lower, min(upper, value)) + + +def _remap_abs_event(device, event): + target_range = _target_abs_range(event.code) + if target_range is None: + return event.code, event.value + + source_abs_info = _source_abs_info(device, event.code) + if source_abs_info is None: + return event.code, event.value + + source_min = source_abs_info.min + source_max = source_abs_info.max + target_min, target_max = target_range + + if source_min == target_min and source_max == target_max: + value = event.value + else: + value = _scale_value(event.value, source_min, source_max, target_min, target_max) + + return (event.code, _clamp(value, target_min, target_max)) + + +def _remap_key_event(event): + # Normalize Linux gamepad buttons onto the intended virtual layout. + key_map = { + ecodes.BTN_A: ecodes.BTN_SOUTH, + ecodes.BTN_B: ecodes.BTN_EAST, + ecodes.BTN_X: ecodes.BTN_WEST, + ecodes.BTN_Y: ecodes.BTN_NORTH, + ecodes.BTN_THUMB: ecodes.BTN_THUMBL, + ecodes.BTN_THUMB2: ecodes.BTN_THUMBR, + ecodes.BTN_TOP: ecodes.BTN_WEST, + ecodes.BTN_TOP2: ecodes.BTN_NORTH, + } + return key_map.get(event.code, event.code), event.value + + +def _remap_trigger_button_event(event): + if event.code == ecodes.BTN_TL2: + return ecodes.ABS_Z, 255 if event.value else 0 + if event.code == ecodes.BTN_TR2: + return ecodes.ABS_RZ, 255 if event.value else 0 + return None + + +def _update_dpad_state(dpad_state, event): + if event.code == ecodes.BTN_DPAD_LEFT: + dpad_state["left"] = bool(event.value) + elif event.code == ecodes.BTN_DPAD_RIGHT: + dpad_state["right"] = bool(event.value) + elif event.code == ecodes.BTN_DPAD_UP: + dpad_state["up"] = bool(event.value) + elif event.code == ecodes.BTN_DPAD_DOWN: + dpad_state["down"] = bool(event.value) + + hat_x = 0 + hat_y = 0 + + if dpad_state["left"] and not dpad_state["right"]: + hat_x = -1 + elif dpad_state["right"] and not dpad_state["left"]: + hat_x = 1 + + if dpad_state["up"] and not dpad_state["down"]: + hat_y = -1 + elif dpad_state["down"] and not dpad_state["up"]: + hat_y = 1 + + return hat_x, hat_y + + +def _write_dpad_state(ui, dpad_state): + hat_x = 0 + hat_y = 0 + + if dpad_state["left"] and not dpad_state["right"]: + hat_x = -1 + elif dpad_state["right"] and not dpad_state["left"]: + hat_x = 1 + + if dpad_state["up"] and not dpad_state["down"]: + hat_y = -1 + elif dpad_state["down"] and not dpad_state["up"]: + hat_y = 1 + + ui.write(ecodes.EV_ABS, ecodes.ABS_HAT0X, hat_x) + ui.write(ecodes.EV_ABS, ecodes.ABS_HAT0Y, hat_y) + ui.write(ecodes.EV_KEY, ecodes.BTN_DPAD_LEFT, int(dpad_state["left"])) + ui.write(ecodes.EV_KEY, ecodes.BTN_DPAD_RIGHT, int(dpad_state["right"])) + ui.write(ecodes.EV_KEY, ecodes.BTN_DPAD_UP, int(dpad_state["up"])) + ui.write(ecodes.EV_KEY, ecodes.BTN_DPAD_DOWN, int(dpad_state["down"])) + ui.syn() + + +def _update_dpad_state_from_hat(dpad_state, code, value): + if code == ecodes.ABS_HAT0X: + dpad_state["left"] = value < 0 + dpad_state["right"] = value > 0 + elif code == ecodes.ABS_HAT0Y: + dpad_state["up"] = value < 0 + dpad_state["down"] = value > 0 + + +def _create_virtual_device(mode): + config = TARGET_DEVICE_CONFIGS[mode] + return evdev.UInput( + create_virtual_capabilities(), + name=config["name"], + vendor=config["vendor"], + product=config["product"], + version=config["version"], + bustype=config["bustype"], + ) + + +def _get_uinput_event_path(ui) -> Optional[str]: + # Different evdev versions expose the device path through different attributes. + try: + if hasattr(ui, "device") and ui.device is not None: + for attr in ("path", "fn"): + value = getattr(ui.device, attr, None) + if value: + return value + except Exception: + pass + + for attr in ("devnode", "path", "fn"): + value = getattr(ui, attr, None) + if value: + return value + + return None + + +def serve_virtual_controller(mode: str, controller_id: Optional[str], exclusive_grab: bool): + if evdev is None: + raise RuntimeError("python-evdev is not installed") + + controllers = enumerate_input_candidates() + selected = None + for controller in controllers: + if controller_id is None or controller["id"] == controller_id: + selected = controller + break + + if not selected: + raise RuntimeError("No compatible controller was found for the helper session") + + input_device = evdev.InputDevice(selected["eventPath"]) + ui = _create_virtual_device(mode) + virtual_cfg = TARGET_DEVICE_CONFIGS[mode] + virtual_event_path = _get_uinput_event_path(ui) + + if exclusive_grab: + input_device.grab() + + selector = selectors.DefaultSelector() + selector.register(input_device.fd, selectors.EVENT_READ) + dpad_state = { + "left": False, + "right": False, + "up": False, + "down": False, + } + + print( + json.dumps( + { + "status": "ready", + "mode": mode, + "controller": selected["name"], + "exclusive": exclusive_grab, + "vendorId": selected.get("vendorId", "0000"), + "productId": selected.get("productId", "0000"), + "virtualDevice": { + "name": virtual_cfg["name"], + "vendorId": f"{virtual_cfg['vendor']:04x}", + "productId": f"{virtual_cfg['product']:04x}", + "version": f"{virtual_cfg['version']:04x}", + "eventPath": virtual_event_path, + }, + } + ), + flush=True, + ) + + try: + while RUNNING: + for _key, _mask in selector.select(timeout=0.25): + for event in input_device.read(): + if event.type == ecodes.EV_KEY: + trigger_remap = _remap_trigger_button_event(event) + if trigger_remap is not None: + code, value = trigger_remap + ui.write(ecodes.EV_ABS, code, value) + ui.syn() + continue + + if event.code in ( + ecodes.BTN_DPAD_LEFT, + ecodes.BTN_DPAD_RIGHT, + ecodes.BTN_DPAD_UP, + ecodes.BTN_DPAD_DOWN, + ): + _update_dpad_state(dpad_state, event) + _write_dpad_state(ui, dpad_state) + continue + + code, value = _remap_key_event(event) + ui.write(event.type, code, value) + ui.syn() + elif event.type == ecodes.EV_ABS: + code, value = _remap_abs_event(input_device, event) + if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): + _update_dpad_state_from_hat(dpad_state, code, value) + _write_dpad_state(ui, dpad_state) + continue + ui.write(event.type, code, value) + ui.syn() + finally: + try: + if exclusive_grab: + input_device.ungrab() + except Exception: + pass + ui.close() + input_device.close() + + +def handle_signal(_signum, _frame): + global RUNNING + RUNNING = False + + +def main(): + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("probe") + subparsers.add_parser("list") + + serve_parser = subparsers.add_parser("serve") + serve_parser.add_argument("--mode", default="xinput") + serve_parser.add_argument("--controller-id") + serve_parser.add_argument("--exclusive-grab", action="store_true") + + arguments = parser.parse_args() + + if arguments.command == "probe": + print(json.dumps(build_probe())) + return 0 + + if arguments.command == "list": + print(json.dumps({"controllers": enumerate_input_candidates()})) + return 0 + + if arguments.command == "serve": + if arguments.mode not in TARGET_DEVICE_CONFIGS: + raise RuntimeError(f"Unsupported serve mode: {arguments.mode}") + serve_virtual_controller( + arguments.mode, + arguments.controller_id, + arguments.exclusive_grab, + ) + return 0 + + return 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(json.dumps({"status": "error", "error": str(error)}), file=sys.stderr) + raise diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 9337c65..0df4960 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -26,12 +26,14 @@ module.exports = { "package.json", "lutris_wrapper.sh", "lutris_wrapper.py", + "controller_helper.py", "src_backend/**/*", "!node_modules/**/{test,tests,example,examples,doc,docs}/**/*", ], asarUnpack: [ "lutris_wrapper.sh", "lutris_wrapper.py", + "controller_helper.py", "electron_preload.cjs", "src_backend/resources/gamecontrollerdb.txt", ], diff --git a/electron.cjs b/electron.cjs index 63983d0..29ea50c 100644 --- a/electron.cjs +++ b/electron.cjs @@ -1,6 +1,10 @@ const { app, Menu } = require("electron"); const { getAppConfig } = require("./src_backend/config_manager.cjs"); +const { + initControllerModeManager, + shutdownControllerModeManager, +} = require("./src_backend/controller_mode_manager.cjs"); const { closeRunningGameProcess, toggleGamePause, @@ -34,6 +38,7 @@ app.on("second-instance", () => { }); app.on("window-all-closed", () => { + shutdownControllerModeManager(); if (getAppConfig().keepGamesRunningOnQuit) { toggleGamePause({ forceStatus: "running" }); } else { @@ -88,6 +93,7 @@ app try { registerIpcHandlers(); + initControllerModeManager(); createWindow(() => { logInfo("Main window closed!"); if (!getAppConfig().keepGamesRunningOnQuit) { diff --git a/electron_preload.cjs b/electron_preload.cjs index 4cc4c00..1e7ed84 100644 --- a/electron_preload.cjs +++ b/electron_preload.cjs @@ -96,6 +96,7 @@ contextBridge.exposeInMainWorld("electronAPI", { // Sdl pollGamepadsSdl: () => ipcRenderer.invoke("poll-gamepads-sdl"), + listControllers: () => ipcRenderer.invoke("list-controllers"), // File Manager listDirectory: (dirPath) => ipcRenderer.invoke("list-directory", dirPath), diff --git a/src_backend/config_manager.cjs b/src_backend/config_manager.cjs index 05ea25c..d2d2b30 100644 --- a/src_backend/config_manager.cjs +++ b/src_backend/config_manager.cjs @@ -17,6 +17,7 @@ const defaultConfig = { keepGamesRunningOnQuit: false, enableUiActionSoundFeedbacks: true, enableSdlInput: false, + controllerInputMode: "native", }; const SUBSCRIPTIONS = {}; diff --git a/src_backend/controller_family_db.cjs b/src_backend/controller_family_db.cjs new file mode 100644 index 0000000..dc4b890 --- /dev/null +++ b/src_backend/controller_family_db.cjs @@ -0,0 +1,72 @@ +const { readFileSync } = require("node:fs"); + +const { localeAppFile, logWarn } = require("./utils.cjs"); + +let controllerDbCache = null; + +const FAMILY_PATTERNS = [ + { family: "playstation", pattern: /dualsense|dualshock|playstation|\bps4\b|\bps5\b|wireless controller|sony/i }, + { family: "xbox", pattern: /xbox|xinput|microsoft/i }, + { family: "nintendo", pattern: /\bswitch\b|joy-?con|pro controller|nintendo/i }, + { family: "steam", pattern: /steam deck|steam controller|valve/i }, + { family: "8bitdo", pattern: /8bitdo/i }, + { family: "generic", pattern: /gamepad|controller|joystick/i }, +]; + +function normalizeHex(value) { + return (value || "0000").toLowerCase().replace(/^0x/, "").padStart(4, "0"); +} + +function loadControllerDatabase() { + if (controllerDbCache) { + return controllerDbCache; + } + + const dbPath = localeAppFile("./src_frontend/resources/gamepad_mapping_db.json"); + if (dbPath instanceof Error) { + logWarn("controller_family_db", "controller mapping database not found"); + controllerDbCache = {}; + return controllerDbCache; + } + + try { + const raw = readFileSync(dbPath, "utf8"); + const parsed = JSON.parse(raw); + controllerDbCache = parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + logWarn("controller_family_db", "unable to parse controller mapping database", error); + controllerDbCache = {}; + } + + return controllerDbCache; +} + +function inferFamilyFromName(name = "") { + const text = name; + for (const { family, pattern } of FAMILY_PATTERNS) { + if (pattern.test(text)) { + return family; + } + } + return "unknown"; +} + +function resolveControllerIdentity(vendorId, productId, fallbackName) { + const db = loadControllerDatabase(); + const key = `${normalizeHex(vendorId)}_${normalizeHex(productId)}`; + const entry = db[key]; + const isRecognized = Boolean(entry?.n); + const standardizedName = entry?.n || fallbackName; + const family = inferFamilyFromName(standardizedName || ""); + + return { + databaseKey: key, + isRecognized, + standardizedName, + family, + }; +} + +module.exports = { + resolveControllerIdentity, +}; diff --git a/src_backend/controller_mode_manager.cjs b/src_backend/controller_mode_manager.cjs new file mode 100644 index 0000000..cde67dd --- /dev/null +++ b/src_backend/controller_mode_manager.cjs @@ -0,0 +1,141 @@ +const { spawn } = require("node:child_process"); + +const { + getAppConfig, + subscribeConfigValueChange, +} = require("./config_manager.cjs"); +const { localeAppFile, logError, logInfo, logWarn } = require("./utils.cjs"); + +let helperProcess = null; +let grabbedDevice = null; +let virtualDevice = null; + +function getControllerHelperPath() { + return localeAppFile("controller_helper.py"); +} + +function startXinputHelper() { + if (helperProcess) { + logWarn("controller_mode_manager", "xinput helper already running"); + return; + } + + let helperPath; + try { + helperPath = getControllerHelperPath(); + } catch { + logError( + "controller_mode_manager", + "controller_helper.py not found, cannot start xinput mode", + ); + return; + } + + logInfo("controller_mode_manager", "starting xinput helper"); + + const proc = spawn( + "python3", + [helperPath, "serve", "--mode", "xinput", "--exclusive-grab"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + + proc.stdout.on("data", (data) => { + const text = stdoutDecoder.decode(data).trim(); + if (text) { + logInfo("controller_mode_manager", text); + try { + const msg = JSON.parse(text); + if (msg.status === "ready" && msg.vendorId && msg.productId) { + grabbedDevice = { + vendorId: msg.vendorId, + productId: msg.productId, + }; + } + if (msg.status === "ready" && msg.virtualDevice) { + virtualDevice = msg.virtualDevice; + } + } catch { + // not JSON, ignore + } + } + }); + + proc.stderr.on("data", (data) => { + const text = stderrDecoder.decode(data).trim(); + if (text) { + logError("controller_mode_manager", text); + } + }); + + proc.on("close", (code) => { + logInfo("controller_mode_manager", `xinput helper exited with code ${code}`); + if (helperProcess === proc) { + helperProcess = null; + grabbedDevice = null; + virtualDevice = null; + } + }); + + proc.on("error", (error) => { + logError("controller_mode_manager", "failed to start xinput helper", error); + if (helperProcess === proc) { + helperProcess = null; + grabbedDevice = null; + virtualDevice = null; + } + }); + + helperProcess = proc; +} + +function stopXinputHelper() { + if (!helperProcess) { + return; + } + + logInfo("controller_mode_manager", "stopping xinput helper"); + helperProcess.kill("SIGTERM"); + helperProcess = null; + grabbedDevice = null; + virtualDevice = null; +} + +function applyInputMode(mode) { + if (mode === "xinput") { + startXinputHelper(); + } else { + stopXinputHelper(); + } +} + +function initControllerModeManager() { + const config = getAppConfig(); + applyInputMode(config.controllerInputMode); + + subscribeConfigValueChange("controllerInputMode", (newMode) => { + logInfo("controller_mode_manager", `input mode changed to: ${newMode}`); + applyInputMode(newMode); + }); +} + +function shutdownControllerModeManager() { + stopXinputHelper(); +} + +function getGrabbedDevice() { + return grabbedDevice; +} + +function getVirtualDevice() { + return virtualDevice; +} + +module.exports = { + initControllerModeManager, + shutdownControllerModeManager, + getGrabbedDevice, + getVirtualDevice, +}; diff --git a/src_backend/game_manager.cjs b/src_backend/game_manager.cjs index bb947e9..4c14eb1 100644 --- a/src_backend/game_manager.cjs +++ b/src_backend/game_manager.cjs @@ -6,6 +6,7 @@ const path = require("node:path"); const { globalShortcut } = require("electron"); const { getAppConfig } = require("./config_manager.cjs"); +const { getGrabbedDevice } = require("./controller_mode_manager.cjs"); const { getCoverartPath, getRuntimeIconPath, @@ -280,13 +281,27 @@ function launchGame(gameId) { } const gameStartTime = Date.now(); + const appConfig = getAppConfig(); + + const spawnOptions = { + detached: appConfig.keepGamesRunningOnQuit, + }; + + const grabbed = getGrabbedDevice(); + if (appConfig.controllerInputMode === "xinput" && grabbed) { + const vid = `0x${grabbed.vendorId}`; + const pid = `0x${grabbed.productId}`; + spawnOptions.env = { + ...process.env, + SDL_GAMECONTROLLER_IGNORE_DEVICES: `${vid}/${pid}`, + SDL_JOYSTICK_IGNORE_DEVICES: `${vid}/${pid}`, + }; + } const newGameProcess = spawn( "bash", [getLutrisWrapperPath(), `lutris:rungameid/${gameId}`], - { - detached: getAppConfig().keepGamesRunningOnQuit, - }, + spawnOptions, ); const stdoutTextDecoder = new TextDecoder(); diff --git a/src_backend/ipc_handlers.cjs b/src_backend/ipc_handlers.cjs index a2b735d..776f1aa 100644 --- a/src_backend/ipc_handlers.cjs +++ b/src_backend/ipc_handlers.cjs @@ -36,7 +36,7 @@ const { getLutrisRunners, } = require("./lutris_wrapper.cjs"); const { serializeGamepads } = require("./sdl_gamepad_serialize.cjs"); -const { mapSdlGamepadsToWebApi, pollGamepads } = require("./sdl_manager.cjs"); +const { mapSdlGamepadsToWebApi, pollGamepads, listControllers } = require("./sdl_manager.cjs"); const { getMainWindow } = require("./state.cjs"); const { getUserTheme } = require("./theme_manager.cjs"); const { @@ -313,6 +313,10 @@ function registerIpcHandlers() { } return await listDirectory(dirPath); }); + + ipcHandleWithError("list-controllers", async () => { + return await listControllers(); + }); } module.exports = { registerIpcHandlers }; diff --git a/src_backend/sdl_bindings.cjs b/src_backend/sdl_bindings.cjs index eb77d3b..a817dee 100644 --- a/src_backend/sdl_bindings.cjs +++ b/src_backend/sdl_bindings.cjs @@ -56,8 +56,30 @@ function bindSDL3(lib) { SDL_GetGamepadName: lib.func( "const char* SDL_GetGamepadName(SDL_Gamepad* gamepad)", ), + SDL_GetGamepadVendor: lib.func( + "Uint16 SDL_GetGamepadVendor(SDL_Gamepad* gamepad)", + ), + SDL_GetGamepadProduct: lib.func( + "Uint16 SDL_GetGamepadProduct(SDL_Gamepad* gamepad)", + ), }; + try { + sdl.SDL_GetGamepadProductVersion = lib.func( + "Uint16 SDL_GetGamepadProductVersion(SDL_Gamepad* gamepad)", + ); + } catch { + sdl.SDL_GetGamepadProductVersion = null; + } + + try { + sdl.SDL_GetGamepadPath = lib.func( + "const char* SDL_GetGamepadPath(SDL_Gamepad* gamepad)", + ); + } catch { + sdl.SDL_GetGamepadPath = null; + } + return sdl; } diff --git a/src_backend/sdl_manager.cjs b/src_backend/sdl_manager.cjs index 744d008..401ba44 100644 --- a/src_backend/sdl_manager.cjs +++ b/src_backend/sdl_manager.cjs @@ -1,3 +1,6 @@ +const { getAppConfig } = require("./config_manager.cjs"); +const { resolveControllerIdentity } = require("./controller_family_db.cjs"); +const { getVirtualDevice } = require("./controller_mode_manager.cjs"); const { SDL3_LIBRARY_NAME, bindSDL3, @@ -186,4 +189,101 @@ function mapSdlGamepadsToWebApi(gamepads) { })); } -module.exports = { pollGamepads, mapSdlGamepadsToWebApi }; +function isAppGeneratedXinputController(controller, virtualDevice) { + return Boolean( + virtualDevice?.eventPath && + controller.path && + virtualDevice.eventPath === controller.path, + ); +} + +async function listControllers() { + const { sdl, activeControllers, koffi } = await getSdlHandle(); + + sdl.SDL_PumpEvents(); + sdl.SDL_UpdateGamepads(); + + const countPtr = new Int32Array(1); + const gamepadsPtr = sdl.SDL_GetGamepads(countPtr); + const numGamepads = countPtr[0]; + const currentInstanceIds = new Set(); + + if (gamepadsPtr) { + try { + const decoded = koffi.decode(gamepadsPtr, "SDL_JoystickID", numGamepads); + for (let i = 0; i < numGamepads; i++) { + currentInstanceIds.add(decoded[i]); + } + } finally { + sdl.SDL_free(gamepadsPtr); + } + } + + for (const [instanceId, ptr] of activeControllers.entries()) { + if (!currentInstanceIds.has(instanceId) || !sdl.SDL_GamepadConnected(ptr)) { + sdl.SDL_CloseGamepad(ptr); + activeControllers.delete(instanceId); + } + } + + const controllers = []; + const appConfig = getAppConfig(); + const virtualDevice = + appConfig?.controllerInputMode === "xinput" ? getVirtualDevice() : null; + + for (const instanceId of currentInstanceIds) { + if (!sdl.SDL_IsGamepad(instanceId)) { + continue; + } + + let ptr = activeControllers.get(instanceId); + if (!ptr) { + ptr = sdl.SDL_OpenGamepad(instanceId); + if (ptr) { + activeControllers.set(instanceId, ptr); + } + } + + if (!ptr || !sdl.SDL_GamepadConnected(ptr)) { + continue; + } + + const rawName = sdl.SDL_GetGamepadName(ptr) || `Controller ${controllers.length + 1}`; + const vendorId = sdl.SDL_GetGamepadVendor(ptr) + .toString(16) + .padStart(4, "0"); + const productId = sdl.SDL_GetGamepadProduct(ptr) + .toString(16) + .padStart(4, "0"); + const version = sdl.SDL_GetGamepadProductVersion + ? sdl.SDL_GetGamepadProductVersion(ptr).toString(16).padStart(4, "0") + : null; + const path = sdl.SDL_GetGamepadPath + ? sdl.SDL_GetGamepadPath(ptr) + : null; + const identity = resolveControllerIdentity(vendorId, productId, rawName); + + const controller = { + index: instanceId, + name: identity.standardizedName, + rawName, + vendorId, + productId, + version, + path, + databaseKey: identity.databaseKey, + isRecognized: identity.isRecognized, + family: identity.family, + }; + + if (isAppGeneratedXinputController(controller, virtualDevice)) { + continue; + } + + controllers.push(controller); + } + + return controllers; +} + +module.exports = { pollGamepads, mapSdlGamepadsToWebApi, listControllers }; diff --git a/src_frontend/assets/controller-models/index.js b/src_frontend/assets/controller-models/index.js new file mode 100644 index 0000000..87b80d0 --- /dev/null +++ b/src_frontend/assets/controller-models/index.js @@ -0,0 +1,130 @@ +import JoyConModel from "./models/nintendo/JoyConModel.jsx"; +import SwitchProModel from "./models/nintendo/SwitchProModel.jsx"; +import DualSenseModel from "./models/playstation/DualSenseModel.jsx"; +import DualShock3Model from "./models/playstation/DualShock3Model.jsx"; +import DualShock4Model from "./models/playstation/DualShock4Model.jsx"; +import Xbox360Model from "./models/xbox/Xbox360Model.jsx"; +import XboxOneModel from "./models/xbox/XboxOneModel.jsx"; +import XboxSeriesModel from "./models/xbox/XboxSeriesModel.jsx"; + +function getControllerText(controller) { + return [ + controller?.name, + controller?.rawName, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +function nameMatches(controller, pattern) { + return pattern.test(getControllerText(controller)); +} + +export const MODEL_REGISTRY = [ + { + id: "playstation-dualsense", + component: DualSenseModel, + matchController: (controller) => + controller?.family === "playstation" && nameMatches(controller, /dualsense|ps5|playstation 5/), + }, + { + id: "playstation-dualshock3", + component: DualShock3Model, + matchController: (controller) => + controller?.family === "playstation" && nameMatches(controller, /dualshock\s*3|\bps3\b|playstation 3/), + }, + { + id: "playstation-dualshock4", + component: DualShock4Model, + matchController: (controller) => + controller?.family === "playstation" && nameMatches(controller, /dualshock\s*4|\bps4\b|playstation 4/), + }, + { + id: "playstation-dualshock4", + component: DualShock4Model, + matchController: (controller) => controller?.family === "playstation", + }, + { + id: "xbox-series", + component: XboxSeriesModel, + matchController: (controller) => + controller?.family === "xbox" && nameMatches(controller, /xbox\s*series|series\s*[xs]/), + }, + { + id: "xbox-360", + component: Xbox360Model, + matchController: (controller) => + controller?.family === "xbox" && nameMatches(controller, /xbox\s*360|x360/), + }, + { + id: "xbox-one", + component: XboxOneModel, + matchController: (controller) => + controller?.family === "xbox" && nameMatches(controller, /xbox\s*one|elite|xbox wireless controller/), + }, + { + id: "xbox-one", + component: XboxOneModel, + matchController: (controller) => controller?.family === "xbox", + }, + { + id: "nintendo-joycon", + component: JoyConModel, + matchController: (controller) => + controller?.family === "nintendo" && nameMatches(controller, /joy-?con|left joy|right joy|combined joy/), + }, + { + id: "nintendo-switch-pro", + component: SwitchProModel, + matchController: (controller) => + controller?.family === "nintendo" && nameMatches(controller, /switch\s*pro|switch 2 pro|pro controller/), + }, + { + id: "nintendo-switch-pro", + component: SwitchProModel, + matchController: (controller) => controller?.family === "nintendo", + }, +]; + +export const MODEL_COMPONENTS = { + "playstation-dualsense": DualSenseModel, + "playstation-dualshock3": DualShock3Model, + "playstation-dualshock4": DualShock4Model, + "xbox-series": XboxSeriesModel, + "xbox-360": Xbox360Model, + "xbox-one": XboxOneModel, + "nintendo-joycon": JoyConModel, + "nintendo-switch-pro": SwitchProModel, +}; + +export function resolveControllerModel(controller) { + for (const model of MODEL_REGISTRY) { + if (model.matchController(controller)) { + return model.id; + } + } + + if (controller?.family === "playstation") { + return "playstation-dualshock4"; + } + + if (controller?.family === "nintendo") { + return "nintendo-switch-pro"; + } + + if (controller?.family === "xbox") { + return "xbox-one"; + } + + return "xbox-one"; +} + +export { default as DualSenseModel } from "./models/playstation/DualSenseModel.jsx"; +export { default as DualShock3Model } from "./models/playstation/DualShock3Model.jsx"; +export { default as DualShock4Model } from "./models/playstation/DualShock4Model.jsx"; +export { default as JoyConModel } from "./models/nintendo/JoyConModel.jsx"; +export { default as SwitchProModel } from "./models/nintendo/SwitchProModel.jsx"; +export { default as Xbox360Model } from "./models/xbox/Xbox360Model.jsx"; +export { default as XboxOneModel } from "./models/xbox/XboxOneModel.jsx"; +export { default as XboxSeriesModel } from "./models/xbox/XboxSeriesModel.jsx"; diff --git a/src_frontend/assets/controller-models/models/nintendo/JoyConModel.jsx b/src_frontend/assets/controller-models/models/nintendo/JoyConModel.jsx new file mode 100644 index 0000000..1bcded8 --- /dev/null +++ b/src_frontend/assets/controller-models/models/nintendo/JoyConModel.jsx @@ -0,0 +1,5 @@ +import SwitchProModel from "./SwitchProModel.jsx"; + +const JoyConModel = (props) => ; + +export default JoyConModel; diff --git a/src_frontend/assets/controller-models/models/nintendo/SwitchProModel.jsx b/src_frontend/assets/controller-models/models/nintendo/SwitchProModel.jsx new file mode 100644 index 0000000..a415ffb --- /dev/null +++ b/src_frontend/assets/controller-models/models/nintendo/SwitchProModel.jsx @@ -0,0 +1,60 @@ +import switchProSvg from "./vendor/switch-pro-controller.svg"; +import TriggerMeter from "../../shared/TriggerMeter.jsx"; +import { + BTN, + getAxis, + getButtonValue, + isPressed, +} from "../../shared/utils.js"; + +// Keep this model asset-based using the Ryujinx ProCon SVG; do not hand-draw the body again. +const SwitchProModel = ({ s }) => { + const accent = "#e60012"; + const stickTravel = 14; + const lx = 213.37 + getAxis(s, 0) * stickTravel; + const ly = 206.39 + getAxis(s, 1) * stickTravel; + const rx = 623.77 + getAxis(s, 2) * stickTravel; + const ry = 345.09 + getAxis(s, 3) * stickTravel; + const lt = getButtonValue(s, BTN.LT); + const rt = getButtonValue(s, BTN.RT); + + return ( +
+ Nintendo Switch Pro controller + + + + + + + + + + + + + + + + + X + B + Y + A + + + + + + {/* Redraw stick wells to mask static sticks from the base asset. */} + + + + + + +
+ ); +}; + +export default SwitchProModel; diff --git a/src_frontend/assets/controller-models/models/nintendo/vendor/switch-pro-controller.svg b/src_frontend/assets/controller-models/models/nintendo/vendor/switch-pro-controller.svg new file mode 100644 index 0000000..53eef82 --- /dev/null +++ b/src_frontend/assets/controller-models/models/nintendo/vendor/switch-pro-controller.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src_frontend/assets/controller-models/models/playstation/DualSenseModel.jsx b/src_frontend/assets/controller-models/models/playstation/DualSenseModel.jsx new file mode 100644 index 0000000..d904491 --- /dev/null +++ b/src_frontend/assets/controller-models/models/playstation/DualSenseModel.jsx @@ -0,0 +1,285 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import controllerBasePng from "./vendor/ps5-controller-visualizer/controller-base.png"; +import stickSpritePng from "./vendor/ps5-controller-visualizer/stick-sprite.png"; +import abxySpritePng from "./vendor/ps5-controller-visualizer/abxy-sprite.png"; +import arrowsSpritePng from "./vendor/ps5-controller-visualizer/arrows-sprite.png"; +import dpadSpritePng from "./vendor/ps5-controller-visualizer/dpad-sprite.png"; +import bumperSpritePng from "./vendor/ps5-controller-visualizer/bumper-sprite.png"; +import triggerSpritePng from "./vendor/ps5-controller-visualizer/trigger-sprite.png"; +import psColorPng from "./vendor/ps5-controller-visualizer/ps-color.png"; +import { + BTN, + getAxis, + getButtonValue, + isPressed, +} from "../../shared/utils.js"; + +const SCENE_WIDTH = 807; +const SCENE_HEIGHT = 651; +const MAX_STICK_MOVE = 15; +const TOUCHPAD_BUTTON_INDEX = 17; + +const abs = (left, top, width, height) => ({ + position: "absolute", + left, + top, + width, + height, +}); + +const spriteStyle = (url) => ({ + backgroundImage: `url(${url})`, + backgroundRepeat: "no-repeat", +}); + +const DualSenseModel = ({ s }) => { + const hostRef = useRef(null); + const [scale, setScale] = useState(1); + + useLayoutEffect(() => { + const host = hostRef.current; + if (!host) return undefined; + + const updateScale = () => { + const next = Math.min(1, host.clientWidth / SCENE_WIDTH); + setScale(Number.isFinite(next) && next > 0 ? next : 1); + }; + + updateScale(); + const observer = new ResizeObserver(updateScale); + observer.observe(host); + + return () => observer.disconnect(); + }, []); + + const crossPressed = isPressed(s, BTN.A); + const circlePressed = isPressed(s, BTN.B); + const squarePressed = isPressed(s, BTN.X); + const trianglePressed = isPressed(s, BTN.Y); + const dpadUpPressed = isPressed(s, BTN.DPAD_UP); + const dpadDownPressed = isPressed(s, BTN.DPAD_DOWN); + const dpadLeftPressed = isPressed(s, BTN.DPAD_LEFT); + const dpadRightPressed = isPressed(s, BTN.DPAD_RIGHT); + const sharePressed = isPressed(s, BTN.SELECT); + const optionsPressed = isPressed(s, BTN.START); + const l1Pressed = isPressed(s, BTN.LB); + const r1Pressed = isPressed(s, BTN.RB); + const l3Pressed = isPressed(s, BTN.L3); + const r3Pressed = isPressed(s, BTN.R3); + const psPressed = isPressed(s, BTN.HOME); + const touchpadPressed = isPressed(s, TOUCHPAD_BUTTON_INDEX); + + const l2 = getButtonValue(s, BTN.LT); + const r2 = getButtonValue(s, BTN.RT); + + const lx = getAxis(s, 0) * MAX_STICK_MOVE; + const ly = getAxis(s, 1) * MAX_STICK_MOVE; + const rx = getAxis(s, 2) * MAX_STICK_MOVE; + const ry = getAxis(s, 3) * MAX_STICK_MOVE; + + return ( +
+
+ DualSense controller + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
0.05 ? l2 : 0, + }} + /> +
0.05 ? r2 : 0, + }} + /> +
+ +
+ +
+
+
+ ); +}; + +export default DualSenseModel; diff --git a/src_frontend/assets/controller-models/models/playstation/DualShock3Model.jsx b/src_frontend/assets/controller-models/models/playstation/DualShock3Model.jsx new file mode 100644 index 0000000..e8e0c22 --- /dev/null +++ b/src_frontend/assets/controller-models/models/playstation/DualShock3Model.jsx @@ -0,0 +1,53 @@ +import dualShock3Svg from "./vendor/dualshock3-controller.svg"; +import { + BTN, + getAxis, + getButtonValue, + isPressed, +} from "../../shared/utils.js"; + +const DualShock3Model = ({ s }) => { + const stickTravel = 10; + const lx = 143.4 + getAxis(s, 0) * stickTravel; + const ly = 154 + getAxis(s, 1) * stickTravel; + const rx = 258 + getAxis(s, 2) * stickTravel; + const ry = 154 + getAxis(s, 3) * stickTravel; + const lt = getButtonValue(s, BTN.LT); + const rt = getButtonValue(s, BTN.RT); + + return ( +
+ DualShock 3 controller + + + + + + + + + + + + Y + B + X + A + + + + + + + + + + + + + +
+ ); +}; + +export default DualShock3Model; diff --git a/src_frontend/assets/controller-models/models/playstation/DualShock4Model.jsx b/src_frontend/assets/controller-models/models/playstation/DualShock4Model.jsx new file mode 100644 index 0000000..ab38111 --- /dev/null +++ b/src_frontend/assets/controller-models/models/playstation/DualShock4Model.jsx @@ -0,0 +1,177 @@ +import TriggerMeter from "../../shared/TriggerMeter.jsx"; +import { BTN, getAxis, getButtonValue, isPressed } from "../../shared/utils.js"; + +const DualShock4Model = ({ s }) => { + const stickTravel = 25; + const lx = 274.65 + getAxis(s, 0) * stickTravel; + const ly = 355.967 + getAxis(s, 1) * stickTravel; + const rx = 531.25 + getAxis(s, 2) * stickTravel; + const ry = 355.967 + getAxis(s, 3) * stickTravel; + const lt = getButtonValue(s, BTN.LT); + const rt = getButtonValue(s, BTN.RT); + const accent = "#006fcd"; + + return ( + + {/* ── Base body (from e7d/gamepad-viewer ds4/base-black.svg, MIT) ── */} + + + {/* ── Grip shading (left) ── */} + + {/* ── Grip shading (right) ── */} + + + {/* ── Left stick well ── */} + + + + + {/* ── Right stick well ── */} + + + + + {/* ── Touchpad ── */} + + + + {/* ── Face button / D-Pad background circles ── */} + + + + {/* ── D-Pad arrow indicators (from base) ── */} + + + {/* ── Create / Options button wells ── */} + + + + {/* ── Speaker grille dots ── */} + + + + + + + + + + + + + + + + {/* ── Light bar ── */} + + + {/* ── Trigger areas (from base) ── */} + + + + + + {/* ── Trigger 3D shapes (from base) ── */} + + + + {/* ── PS button (from base) ── */} + + + + + + {isPressed(s, BTN.HOME) && } + + {/* ═══ Interactive overlays ═══ */} + + {/* ── D-Pad arrows (from e7d ds4/dpad.svg, each path individually positioned via svgpathtools bbox) ── */} + {/* Up — path center (17.71,25.50) → target (154,207) */} + + {/* Down — path center (55.10,25.50) → target (154,281) */} + + {/* Left — path center (153.05,17.85) → target (118,243) */} + + {/* Right — path center (100.26,17.76) → target (191,243) */} + + + {/* ── Face buttons (PlayStation symbols — e7d ds4/buttons.svg paths, scaled to 70% of r=26 circle) ── */} + {/* Cross (A) - bottom */} + + + + + {/* Circle (B) - right */} + + + + + {/* Square (X) - left */} + + + + + {/* Triangle (Y) - top */} + + + + + + {/* ── Create button ── */} + + CRE + + {/* ── Options button ── */} + + OPT + + {/* ── L1 bumper ── */} + + + + L1 + + + + {/* ── R1 bumper ── */} + + + + R1 + + + + {/* ── Left stick knob ── */} + + + + + {/* ── Right stick knob ── */} + + + + + + ); +}; + +/* ═══════════════════════════════════════════════════════════════ + Nintendo Pro Controller — hand-crafted (no e7d template) + ═══════════════════════════════════════════════════════════════ */ + + +export default DualShock4Model; diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/dualshock3-controller.svg b/src_frontend/assets/controller-models/models/playstation/vendor/dualshock3-controller.svg new file mode 100644 index 0000000..4e66fe9 --- /dev/null +++ b/src_frontend/assets/controller-models/models/playstation/vendor/dualshock3-controller.svg @@ -0,0 +1,36 @@ + + + + + + + + + +SELECT + +START + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/abxy-sprite.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/abxy-sprite.png new file mode 100644 index 0000000..725e8e1 Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/abxy-sprite.png differ diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/arrows-sprite.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/arrows-sprite.png new file mode 100644 index 0000000..d235e3f Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/arrows-sprite.png differ diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/bumper-sprite.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/bumper-sprite.png new file mode 100644 index 0000000..79bcbdc Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/bumper-sprite.png differ diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/controller-base.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/controller-base.png new file mode 100644 index 0000000..57ca3cb Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/controller-base.png differ diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/dpad-sprite.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/dpad-sprite.png new file mode 100644 index 0000000..b92f75d Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/dpad-sprite.png differ diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/ps-color.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/ps-color.png new file mode 100644 index 0000000..557cdba Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/ps-color.png differ diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/stick-sprite.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/stick-sprite.png new file mode 100644 index 0000000..677db9b Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/stick-sprite.png differ diff --git a/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/trigger-sprite.png b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/trigger-sprite.png new file mode 100644 index 0000000..b71a9d7 Binary files /dev/null and b/src_frontend/assets/controller-models/models/playstation/vendor/ps5-controller-visualizer/trigger-sprite.png differ diff --git a/src_frontend/assets/controller-models/models/xbox/Xbox360Model.jsx b/src_frontend/assets/controller-models/models/xbox/Xbox360Model.jsx new file mode 100644 index 0000000..a220615 --- /dev/null +++ b/src_frontend/assets/controller-models/models/xbox/Xbox360Model.jsx @@ -0,0 +1,5 @@ +import XboxOneModel from "./XboxOneModel.jsx"; + +const Xbox360Model = (props) => ; + +export default Xbox360Model; diff --git a/src_frontend/assets/controller-models/models/xbox/XboxOneModel.jsx b/src_frontend/assets/controller-models/models/xbox/XboxOneModel.jsx new file mode 100644 index 0000000..336eb84 --- /dev/null +++ b/src_frontend/assets/controller-models/models/xbox/XboxOneModel.jsx @@ -0,0 +1,138 @@ +import TriggerMeter from "../../shared/TriggerMeter.jsx"; +import { + BTN, + btnFill, + btnStroke, + getAxis, + getButtonValue, + isPressed, +} from "../../shared/utils.js"; + +const XboxOneModel = ({ s }) => { + const stickTravel = 25; + const lx = 185.468 + getAxis(s, 0) * stickTravel; + const ly = 279.865 + getAxis(s, 1) * stickTravel; + const rx = 473.042 + getAxis(s, 2) * stickTravel; + const ry = 393.815 + getAxis(s, 3) * stickTravel; + const lt = getButtonValue(s, BTN.LT); + const rt = getButtonValue(s, BTN.RT); + const accent = "#107c10"; + + return ( + + + {/* ── Base body (from e7d/gamepad-viewer xbox-one/base-black.svg, MIT) ── */} + + + + {/* ── D-Pad well & cross ── */} + + + + + {/* ── View / Menu buttons ── */} + + + + {/* ── Face plate / Top bar ── */} + + + + {/* ── Left stick well ── */} + + + + + + {/* ── Right stick well ── */} + + + + + + {/* ── Triggers (from e7d xbox-one/trigger.svg) ── */} + + + + + + + + + + {/* ── Xbox guide button ── */} + + + + {/* ── Face buttons (ABXY) — positions from e7d CSS ── */} + {/* A - bottom */} + + A + {/* B - right */} + + B + {/* X - left */} + + X + {/* Y - top */} + + Y + + {/* ── View button (select) ── */} + + + + + + {/* ── Menu button (start) ── */} + + + + + + {/* ── D-Pad interactive overlay ── */} + + {/* cross background */} + + + {/* Up */} + + {/* Down */} + + {/* Left */} + + {/* Right */} + + + + {/* ── Left bumper (LB) ── */} + + + + LB + + + + {/* ── Right bumper (RB) ── */} + + + + RB + + + + {/* ── Left stick knob ── */} + + + + + {/* ── Right stick knob ── */} + + + + + + ); +}; + +export default XboxOneModel; diff --git a/src_frontend/assets/controller-models/models/xbox/XboxSeriesModel.jsx b/src_frontend/assets/controller-models/models/xbox/XboxSeriesModel.jsx new file mode 100644 index 0000000..6a82fa1 --- /dev/null +++ b/src_frontend/assets/controller-models/models/xbox/XboxSeriesModel.jsx @@ -0,0 +1,5 @@ +import XboxOneModel from "./XboxOneModel.jsx"; + +const XboxSeriesModel = (props) => ; + +export default XboxSeriesModel; diff --git a/src_frontend/assets/controller-models/shared/TriggerMeter.jsx b/src_frontend/assets/controller-models/shared/TriggerMeter.jsx new file mode 100644 index 0000000..4cf023f --- /dev/null +++ b/src_frontend/assets/controller-models/shared/TriggerMeter.jsx @@ -0,0 +1,50 @@ +const TriggerMeter = ({ + x, + y, + width, + label, + value, + accent, + align = "start", +}) => { + const alignment = { + center: { barX: -(width / 2), textAnchor: "middle" }, + end: { barX: -width, textAnchor: "end" }, + start: { barX: 0, textAnchor: "start" }, + }; + const { barX, textAnchor } = alignment[align] || alignment.start; + + return ( + + + {label} {Math.round(value * 100)}% + + + + + ); +}; + +export default TriggerMeter; diff --git a/src_frontend/assets/controller-models/shared/utils.js b/src_frontend/assets/controller-models/shared/utils.js new file mode 100644 index 0000000..1537c83 --- /dev/null +++ b/src_frontend/assets/controller-models/shared/utils.js @@ -0,0 +1,40 @@ +export const BTN = { + A: 0, + B: 1, + X: 2, + Y: 3, + LB: 4, + RB: 5, + LT: 6, + RT: 7, + SELECT: 8, + START: 9, + L3: 10, + R3: 11, + DPAD_UP: 12, + DPAD_DOWN: 13, + DPAD_LEFT: 14, + DPAD_RIGHT: 15, + HOME: 16, +}; + +export function getButtonValue(state, index) { + return state?.buttons?.[index]?.value ?? 0; +} + +export function isPressed(state, index) { + return state?.buttons?.[index]?.pressed ?? false; +} + +export function getAxis(state, index) { + return state?.axes?.[index] ?? 0; +} + +export const btnFill = (pressed, color) => + pressed ? color : "rgba(255,255,255,0.06)"; + +export const btnStroke = (pressed, color) => + pressed ? color : "rgba(255,255,255,0.18)"; + +export const txtFill = (pressed, color) => + pressed ? "#fff" : `${color}aa`; diff --git a/src_frontend/components/ControllerSettings.jsx b/src_frontend/components/ControllerSettings.jsx new file mode 100644 index 0000000..d4a0d49 --- /dev/null +++ b/src_frontend/components/ControllerSettings.jsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useTranslation } from "../contexts/TranslationContext"; +import * as api from "../utils/ipc"; + +import ControllerTester from "./ControllerTester"; +import DialogLayout from "./DialogLayout"; +import FocusableRow from "./FocusableRow"; +import LoadingIndicator from "./LoadingIndicator"; +import RowBasedMenu from "./RowBasedMenu"; + +import "../styles/ControllerSettings.css"; + +export const ControllerSettingsFocusId = "ControllerSettings"; +const ControllerDetailFocusId = "ControllerDetail"; + +const INPUT_MODES = ["native", "xinput"]; + +/* ── Detail view for a single controller ── */ + +const ControllerDetail = ({ controller, onBack }) => { + const { t } = useTranslation(); + const [inputMode, setInputMode] = useState("native"); + const [showTester, setShowTester] = useState(false); + const scrollParentReference = useRef(null); + const isXboxFamily = controller?.family === "xbox"; + + useEffect(() => { + api + .getAppConfig() + .then((cfg) => { + const configuredMode = cfg?.controllerInputMode || "native"; + const resolvedMode = isXboxFamily ? "native" : configuredMode; + + setInputMode(resolvedMode); + + if (isXboxFamily && configuredMode !== "native") { + api.setAppConfig("controllerInputMode", "native"); + } + }) + .catch(() => {}); + }, [isXboxFamily]); + + const cycleInputMode = useCallback(() => { + if (isXboxFamily) { + return; + } + + setInputMode((prev) => { + const next = + INPUT_MODES[(INPUT_MODES.indexOf(prev) + 1) % INPUT_MODES.length]; + api.setAppConfig("controllerInputMode", next); + return next; + }); + }, [isXboxFamily]); + + const handleAction = useCallback( + (actionName, item) => { + if (actionName === "B") { + onBack(); + return; + } + if (actionName === "A" && item) { + if (item.id === "input-mode") { + cycleInputMode(); + } else if (item.id === "test-controller") { + setShowTester(true); + } + } + }, + [onBack, cycleInputMode], + ); + + const modeLabel = inputMode === "native" ? t("Native") : t("Xinput"); + + const menuItems = useMemo( + () => [ + { + id: "input-mode", + label: t("Input Mode"), + value: modeLabel, + disabled: isXboxFamily, + }, + { + id: "test-controller", + label: t("Test Controller"), + }, + ], + [isXboxFamily, modeLabel, t], + ); + + const renderItem = useCallback( + (item, isFocused, onMouseEnter) => ( + +
+ {item.label} + {item.value && ( + + {item.value} + + )} +
+
+ ), + [], + ); + + const legendItems = useMemo( + () => [ + { button: "A", label: t("Select") }, + { button: "B", label: t("Back") }, + ], + [t], + ); + + if (showTester) { + return ( + setShowTester(false)} + /> + ); + } + + return ( + + item.id} + scrollParentRef={scrollParentReference} + /> + + ); +}; + +/* ── Controller list (top-level) ── */ + +const ControllerSettings = ({ onClose }) => { + const { t } = useTranslation(); + const [controllers, setControllers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedController, setSelectedController] = useState(null); + const [inputMode, setInputMode] = useState("native"); + const currentMenuItem = useRef(null); + const scrollParentReference = useRef(null); + + const fetchControllers = useCallback(async () => { + setIsLoading(true); + try { + const [result, config] = await Promise.all([ + api.listControllers(), + api.getAppConfig(), + ]); + setControllers(result || []); + if (config?.controllerInputMode) { + setInputMode(config.controllerInputMode); + } + } catch { + setControllers([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchControllers(); + }, [fetchControllers]); + + const handleAction = useCallback( + (actionName, item) => { + switch (actionName) { + case "A": { + if (item?.controller) { + setSelectedController(item.controller); + } + break; + } + case "B": { + onClose(); + break; + } + case "Y": { + fetchControllers(); + break; + } + } + }, + [onClose, fetchControllers], + ); + + const menuItems = useMemo( + () => + controllers.map((controller) => ({ + id: `controller-${controller.index}`, + label: controller.name, + controller, + })), + [controllers], + ); + + const renderItem = useCallback( + (item, isFocused, onMouseEnter) => ( + +
+
+ {item.controller.name} + + {inputMode} + +
+ + {[ + item.controller.family, + `${item.controller.vendorId}:${item.controller.productId}`, + ] + .filter(Boolean) + .join(" · ")} + +
+
+ ), + [inputMode], + ); + + const legendItems = useMemo( + () => [ + { button: "A", label: t("Select") }, + { button: "Y", label: t("Refresh"), onClick: fetchControllers }, + { button: "B", label: t("Close"), onClick: onClose }, + ], + [onClose, fetchControllers, t], + ); + + if (selectedController) { + return ( + { + setSelectedController(null); + fetchControllers(); + }} + /> + ); + } + + return ( + + {isLoading ? ( +
+ +
+ ) : ( + item.id} + onFocusChange={(item) => { + currentMenuItem.current = item; + }} + scrollParentRef={scrollParentReference} + emptyMessage={t("No controllers detected.")} + /> + )} +
+ ); +}; + +export default ControllerSettings; diff --git a/src_frontend/components/ControllerTester.jsx b/src_frontend/components/ControllerTester.jsx new file mode 100644 index 0000000..30f6c89 --- /dev/null +++ b/src_frontend/components/ControllerTester.jsx @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + MODEL_COMPONENTS, + resolveControllerModel, +} from "../assets/controller-models/index.js"; +import { useTranslation } from "../contexts/TranslationContext"; +import { useScopedInput } from "../hooks/useScopedInput"; +import * as api from "../utils/ipc"; + +import DialogLayout from "./DialogLayout"; + +import "../styles/ControllerTester.css"; + +export const ControllerTesterFocusId = "ControllerTester"; + +const ControllerTester = ({ onClose, controller }) => { + const { t } = useTranslation(); + const [gamepadState, setGamepadState] = useState(null); + const rafRef = useRef(null); + + const modelId = useMemo( + () => resolveControllerModel(controller), + [controller], + ); + const ModelComponent = MODEL_COMPONENTS[modelId] || MODEL_COMPONENTS.xbox; + + useScopedInput( + (event) => { + if (event.name === "B") { + onClose(); + event.isConsumed = true; + } + }, + ControllerTesterFocusId, + ); + + useEffect(() => { + let active = true; + + const poll = async () => { + if (!active) return; + try { + const gamepads = await api.pollGamepadsSdl(); + if (!active) return; + const gp = gamepads.find((g) => g.index === controller?.index) ?? gamepads[0] ?? null; + setGamepadState(gp); + } catch { + // SDL not available — skip + } + if (active) { + rafRef.current = requestAnimationFrame(poll); + } + }; + + rafRef.current = requestAnimationFrame(poll); + return () => { + active = false; + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [controller]); + + const legendItems = useMemo( + () => [{ button: "B", label: t("Close"), onClick: onClose }], + [onClose, t], + ); + + const s = gamepadState; + + return ( + + {s ? ( +
+ +
+ ) : ( +
{t("Waiting for controller input...")}
+ )} +
+ ); +}; + +export default ControllerTester; diff --git a/src_frontend/components/SystemMenu.jsx b/src_frontend/components/SystemMenu.jsx index ffad843..043f7c6 100644 --- a/src_frontend/components/SystemMenu.jsx +++ b/src_frontend/components/SystemMenu.jsx @@ -14,6 +14,7 @@ import * as api from "../utils/ipc"; import About from "./About"; import BluetoothMenu from "./BluetoothMenu"; import ConfirmationDialog from "./ConfirmationDialog"; +import ControllerSettings from "./ControllerSettings"; import DisplaySettings from "./DisplaySettings"; import LegendaContainer from "./LegendaContainer"; import LutrisSettingsFlow from "./LutrisSettingsFlow"; @@ -74,6 +75,13 @@ const SystemMenu = () => { setIsOpen(false); }, [showModal, setIsOpen]); + const openControllerSettingsModal = useCallback(() => { + showModal((hideThisModal) => ( + + )); + setIsOpen(false); + }, [showModal, setIsOpen]); + const openAboutModal = useCallback(() => { showModal((hideThisModal) => ); setIsOpen(false); @@ -131,6 +139,10 @@ const SystemMenu = () => { action: openBluetoothSettingsModal, disabled: staticSettings.DISABLE_BLUETOOTH_SETTINGS, }, + { + label: t("Controller Settings"), + action: openControllerSettingsModal, + }, { label: t("Open Lutris"), action: () => api.openLutris(), @@ -177,6 +189,7 @@ const SystemMenu = () => { openAudioSettingsModal, openDisplaySettingsModal, openBluetoothSettingsModal, + openControllerSettingsModal, settings.doubleConfirmPowerManagement, staticSettings.DISABLE_LUTRIS_SETTINGS, staticSettings.DISABLE_AUDIO_SETTINGS, diff --git a/src_frontend/locale/locale.en.json b/src_frontend/locale/locale.en.json index 52a4da5..86ac7d1 100644 --- a/src_frontend/locale/locale.en.json +++ b/src_frontend/locale/locale.en.json @@ -32,6 +32,23 @@ "Confirm": "Confirm", "Select": "Select" }, + "components/ControllerSettings.jsx": { + "Back": "Back", + "Close": "Close", + "Controller Settings": "Controller Settings", + "Input Mode": "Input Mode", + "Native": "Native", + "No controllers detected.": "No controllers detected.", + "Refresh": "Refresh", + "Select": "Select", + "Test Controller": "Test Controller", + "Xinput": "Xinput" + }, + "components/ControllerTester.jsx": { + "Close": "Close", + "Controller Test": "Controller Test", + "Waiting for controller input...": "Waiting for controller input..." + }, "components/ControlsOverlay.jsx": { "Clear Search": "Clear Search", "Force close": "Force close", @@ -172,6 +189,7 @@ "Continue with exiting the application?": "Continue with exiting the application?", "Continue with system power off?": "Continue with system power off?", "Continue with system reboot?": "Continue with system reboot?", + "Controller Settings": "Controller Settings", "Display Settings": "Display Settings", "Exit Application": "Exit Application", "Generate Bug Report": "Generate Bug Report", diff --git a/src_frontend/locale/locale.fa.json b/src_frontend/locale/locale.fa.json index 8b036a2..93b832b 100644 --- a/src_frontend/locale/locale.fa.json +++ b/src_frontend/locale/locale.fa.json @@ -32,6 +32,23 @@ "Confirm": "پذیرفتن", "Select": "انتخاب" }, + "components/ControllerSettings.jsx": { + "Back": "Back", + "Close": "Close", + "Controller Settings": "Controller Settings", + "Input Mode": "Input Mode", + "Native": "Native", + "No controllers detected.": "No controllers detected.", + "Refresh": "Refresh", + "Select": "Select", + "Test Controller": "Test Controller", + "Xinput": "Xinput" + }, + "components/ControllerTester.jsx": { + "Close": "Close", + "Controller Test": "Controller Test", + "Waiting for controller input...": "Waiting for controller input..." + }, "components/ControlsOverlay.jsx": { "Clear Search": "پاک‌کردن جستجو", "Force close": "بستن اجباری", @@ -172,6 +189,7 @@ "Continue with exiting the application?": "ادامه دادن به خروج از برنامه؟", "Continue with system power off?": "ادامه دادن به خاموش کردن سامانه؟", "Continue with system reboot?": "ادامه دادن به باز راه‌اندازی سامانه؟", + "Controller Settings": "Controller Settings", "Display Settings": "Display Settings", "Exit Application": "خروج از برنامه", "Generate Bug Report": "Generate Bug Report", diff --git a/src_frontend/locale/locale.it.json b/src_frontend/locale/locale.it.json index e23950e..260ae00 100644 --- a/src_frontend/locale/locale.it.json +++ b/src_frontend/locale/locale.it.json @@ -32,6 +32,23 @@ "Confirm": "Conferma", "Select": "Seleziona" }, + "components/ControllerSettings.jsx": { + "Back": "Back", + "Close": "Close", + "Controller Settings": "Controller Settings", + "Input Mode": "Input Mode", + "Native": "Native", + "No controllers detected.": "No controllers detected.", + "Refresh": "Refresh", + "Select": "Select", + "Test Controller": "Test Controller", + "Xinput": "Xinput" + }, + "components/ControllerTester.jsx": { + "Close": "Close", + "Controller Test": "Controller Test", + "Waiting for controller input...": "Waiting for controller input..." + }, "components/ControlsOverlay.jsx": { "Clear Search": "Annulla ricerca", "Force close": "Forza chiusura di", @@ -172,6 +189,7 @@ "Continue with exiting the application?": "Uscire dall'applicazione?", "Continue with system power off?": "Procedere con lo spegnimento del sistema?", "Continue with system reboot?": "Procedere con il riavvio del sistema?", + "Controller Settings": "Controller Settings", "Display Settings": "Display Settings", "Exit Application": "Esci", "Generate Bug Report": "Genera segnalazione bug", diff --git a/src_frontend/locale/locale.sv.json b/src_frontend/locale/locale.sv.json index e04e538..5967aaf 100644 --- a/src_frontend/locale/locale.sv.json +++ b/src_frontend/locale/locale.sv.json @@ -32,6 +32,23 @@ "Confirm": "Bekräfta", "Select": "Välj" }, + "components/ControllerSettings.jsx": { + "Back": "Back", + "Close": "Close", + "Controller Settings": "Controller Settings", + "Input Mode": "Input Mode", + "Native": "Native", + "No controllers detected.": "No controllers detected.", + "Refresh": "Refresh", + "Select": "Select", + "Test Controller": "Test Controller", + "Xinput": "Xinput" + }, + "components/ControllerTester.jsx": { + "Close": "Close", + "Controller Test": "Controller Test", + "Waiting for controller input...": "Waiting for controller input..." + }, "components/ControlsOverlay.jsx": { "Clear Search": "Rensa sökning", "Force close": "Tvinga stängning av", @@ -172,6 +189,7 @@ "Continue with exiting the application?": "Fortsätt med att avsluta programmet?", "Continue with system power off?": "Fortsätt med att stänga av systemet?", "Continue with system reboot?": "Fortsätt med att starta om systemet?", + "Controller Settings": "Controller Settings", "Display Settings": "Display Settings", "Exit Application": "Avsluta programmet", "Generate Bug Report": "Generate Bug Report", diff --git a/src_frontend/locale/locale.zh.json b/src_frontend/locale/locale.zh.json index 7b25657..8740ec8 100644 --- a/src_frontend/locale/locale.zh.json +++ b/src_frontend/locale/locale.zh.json @@ -32,6 +32,23 @@ "Confirm": "确认", "Select": "选择" }, + "components/ControllerSettings.jsx": { + "Back": "Back", + "Close": "Close", + "Controller Settings": "Controller Settings", + "Input Mode": "Input Mode", + "Native": "Native", + "No controllers detected.": "No controllers detected.", + "Refresh": "Refresh", + "Select": "Select", + "Test Controller": "Test Controller", + "Xinput": "Xinput" + }, + "components/ControllerTester.jsx": { + "Close": "Close", + "Controller Test": "Controller Test", + "Waiting for controller input...": "Waiting for controller input..." + }, "components/ControlsOverlay.jsx": { "Clear Search": "清除搜索", "Force close": "强制关闭", @@ -172,6 +189,7 @@ "Continue with exiting the application?": "继续退出应用吗?", "Continue with system power off?": "继续关闭系统吗?", "Continue with system reboot?": "继续重启系统吗?", + "Controller Settings": "Controller Settings", "Display Settings": "显示设置", "Exit Application": "退出应用", "Generate Bug Report": "生成错误报告", diff --git a/src_frontend/styles/ControllerSettings.css b/src_frontend/styles/ControllerSettings.css new file mode 100644 index 0000000..74f31e0 --- /dev/null +++ b/src_frontend/styles/ControllerSettings.css @@ -0,0 +1,69 @@ +.cs-loading { + padding: 24px 0; + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.cs-row-content { + display: flex; + align-items: center; + gap: 12px; + flex-grow: 1; + min-width: 0; + overflow: hidden; +} + +.cs-row-content.disabled { + opacity: 0.45; +} + +.cs-row-value { + margin-left: auto; + color: var(--accent-color); + font-weight: 600; + text-transform: capitalize; + font-size: 0.9rem; +} + +.cs-row-value.disabled { + color: var(--text-secondary-color, rgba(255, 255, 255, 0.72)); +} + +.cs-row-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.controller-settings-device { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; +} + +.controller-settings-device-header { + display: flex; + align-items: center; + gap: 10px; +} + +.controller-settings-mode-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + background: rgba(255, 80, 80, 0.15); + color: #ff5050; +} + +.controller-settings-meta { + color: var(--text-secondary-color, rgba(255, 255, 255, 0.72)); + font-size: 0.9rem; + text-transform: capitalize; +} diff --git a/src_frontend/styles/ControllerTester.css b/src_frontend/styles/ControllerTester.css new file mode 100644 index 0000000..8e10001 --- /dev/null +++ b/src_frontend/styles/ControllerTester.css @@ -0,0 +1,46 @@ +.ct-no-data { + padding: 32px 0; + text-align: center; + color: var(--text-secondary); + font-size: 1rem; +} + +.ct-layout { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; +} + +.ct-svg { + width: 100%; + max-width: 620px; + height: auto; + filter: drop-shadow(0 4px 24px rgba(0, 0, 0, 0.5)); +} + +.ct-stick-anim { + transition: cx 0.04s linear, cy 0.04s linear, fill 0.08s; +} + +.ct-model-wrap { + position: relative; + width: 100%; + max-width: 620px; + filter: drop-shadow(0 4px 24px rgba(0, 0, 0, 0.5)); +} + +.ct-model-base { + display: block; + width: 100%; + height: auto; + background: transparent; + border-radius: 12px; +} + +.ct-model-overlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} diff --git a/src_frontend/utils/ipc.js b/src_frontend/utils/ipc.js index 02288e4..60fc6da 100644 --- a/src_frontend/utils/ipc.js +++ b/src_frontend/utils/ipc.js @@ -96,6 +96,7 @@ export const onAppConfigChanged = createSubscriber("app-config-changed"); export const createBugReportFile = () => globalThis.electronAPI.createBugReportFile(); +export const listControllers = () => globalThis.electronAPI.listControllers(); export const pollGamepadsSdl = async () => deserializeGamepads(await globalThis.electronAPI.pollGamepadsSdl());