react-native-earl-gamepad 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,23 @@
1
1
  # react-native-earl-gamepad
2
+
3
+ ![GitHub stars](https://img.shields.io/github/stars/Swif7ify/react-native-earl-gamepad?style=social)
2
4
  ![npm](https://img.shields.io/npm/v/react-native-earl-gamepad)
3
5
  ![downloads](https://img.shields.io/npm/dm/react-native-earl-gamepad)
4
6
  ![license](https://img.shields.io/npm/l/react-native-earl-gamepad)
5
7
 
6
- WebView-based gamepad bridge for React Native. Polls `navigator.getGamepads()` in a hidden WebView and surfaces buttons, sticks, d-pad, and connection events to JS.
8
+ WebView-based gamepad bridge for React Native. Polls `navigator.getGamepads()` in a hidden WebView and surfaces buttons, sticks, d-pad, touchpad click, and connection events to JS.
9
+
7
10
  - Components: `GamepadBridge`, `useGamepad`, and `GamepadDebug`.
8
- - Deadzone handling (default `0.15`) with auto-clear on disconnect.
9
- - Typed events for buttons, axes, d-pad, and status.
11
+ - Deadzone handling (default `0.15`) with auto-clear on disconnect and live state snapshots to avoid stuck buttons.
12
+ - Typed events for buttons, axes, d-pad, touchpad click, status, and a full-state snapshot.
13
+
14
+ ### Why this?
15
+
16
+ Native gamepad support in React Native can be flaky or hard to maintain. Instead of relying on old native modules, it uses a hidden WebView to bridge the HTML5 Gamepad API (navigator.getGamepads()) directly to React Native. This ensures much better compatibility across iOS and Android since it relies on the web standard.
17
+
18
+ ### Controller Compatibility
19
+
20
+ - Tested with: PS4, and generic Bluetooth controllers. Supports standard mapping.
10
21
 
11
22
  ## Requirements
12
23
 
@@ -121,7 +132,12 @@ export function HUD() {
121
132
 
122
133
  ### Visual debugger
123
134
 
124
- Drop-in component to see a controller diagram that lights up buttons, shows stick offsets, and lists state.
135
+ Drop-in component to see a controller diagram that lights up buttons, shows stick offsets, and lists state. Shows live metadata (name/vendor/product, mapping, axes/buttons count, vibration support) and includes vibration test buttons plus a loader prompt when no pad is connected.
136
+
137
+ The State panel includes:
138
+
139
+ - Per-stick plots (left/right) with axis values, crosshairs, and a dashed trace from center to the current dot.
140
+ - Touchpad click indicator (PS touchpad click is mapped to `touchpad`; position is not exposed by the Gamepad API).
125
141
 
126
142
  ```tsx
127
143
  import { GamepadDebug } from "react-native-earl-gamepad";
@@ -131,9 +147,9 @@ export function DebugScreen() {
131
147
  }
132
148
  ```
133
149
 
134
- ![Gamepad visual idle](https://github.com/user-attachments/assets/dfebd8c5-7d9a-42c7-802b-2773ec8c8ae9)
135
-
136
- ![Gamepad visual pressed](https://github.com/user-attachments/assets/7b37d76a-7695-4be9-bda4-7e3d1e6adf41)
150
+ ![Gamepad visual idle](https://github.com/user-attachments/assets/ae29d0a9-ded5-4b2d-96f8-f432f99cb54c)
151
+ ![Gamepad visual pressed](https://github.com/user-attachments/assets/830323aa-0f6c-4ee4-a276-663a421b9697)
152
+ ![Gamepad Idle](https://github.com/user-attachments/assets/206fe108-8ec3-40cb-a64b-de058d07672f)
137
153
 
138
154
  ## API
139
155
 
@@ -145,6 +161,7 @@ export function DebugScreen() {
145
161
  - `onAxis?: (event: AxisEvent) => void` — fired when an axis changes beyond threshold.
146
162
  - `onDpad?: (event: DpadEvent) => void` — convenience mapping of button indices 12–15.
147
163
  - `onStatus?: (event: StatusEvent) => void` — `connected` / `disconnected` events.
164
+ - `onState?: (event: StateEvent) => void` — full snapshot of pressed buttons, values, and axes each poll.
148
165
  - `style?: StyleProp<ViewStyle>` — override container; default is a 1×1 transparent view.
149
166
 
150
167
  ### `useGamepad` options and return
@@ -162,6 +179,9 @@ Return shape:
162
179
  - `buttonValues: Partial<Record<GamepadButtonName, number>>` — last analog value per button (useful for LT/RT triggers).
163
180
  - `isPressed(key: GamepadButtonName): boolean` — helper to check a single button.
164
181
  - `bridge: JSX.Element | null` — render once to enable polling.
182
+ - `info: GamepadInfo` — metadata for the first controller (id, vendor/product if exposed, mapping, counts, vibration support, timestamp, index).
183
+ - `vibrate(duration?: number, strength?: number): void` — fire a short rumble when `vibrationActuator` is available.
184
+ - `stopVibration(): void` — stop an in-flight vibration when supported.
165
185
 
166
186
  ### `GamepadDebug`
167
187
 
@@ -174,6 +194,8 @@ Return shape:
174
194
  - `AxisEvent`: `{ type: 'axis'; axis: StickAxisName; index: number; value: number }`
175
195
  - `DpadEvent`: `{ type: 'dpad'; key: 'up' | 'down' | 'left' | 'right'; pressed: boolean }`
176
196
  - `StatusEvent`: `{ type: 'status'; state: 'connected' | 'disconnected' }`
197
+ - `InfoEvent`: controller metadata payload (name/vendor/product, mapping, counts, vibration capability, timestamp, index, etc.)
198
+ - `StateEvent`: `{ type: 'state'; pressed: GamepadButtonName[]; values: Record<GamepadButtonName, number>; axes: Record<StickAxisName, number> }`
177
199
 
178
200
  Button names map to the standard gamepad layout (`a`, `b`, `x`, `y`, `lb`, `rb`, `lt`, `rt`, `back`, `start`, `ls`, `rs`, `dpadUp`, `dpadDown`, `dpadLeft`, `dpadRight`, `home`). Unknown indices fall back to `button-N`. Axes map to `leftX`, `leftY`, `rightX`, `rightY` with fallbacks `axis-N`.
179
201
 
@@ -199,13 +221,13 @@ npm install
199
221
  npm run build
200
222
  ```
201
223
 
224
+ Build outputs to `dist/` with type declarations.
225
+
202
226
  ## Troubleshooting
203
227
 
204
228
  - **[Invariant Violation: Tried to register two views with the same name RNCWebView]**: Check your `package.json` for multiple instances of `react-native-webview` and uninstall any duplicates.
205
229
  When you install `react-native-earl-gamepad`, `react-native-webview` is already included, so you should not install it separately. or you can check it by running `npm ls react-native-webview`.
206
230
 
207
- Build outputs to `dist/` with type declarations.
208
-
209
231
  ## License
210
232
 
211
233
  MIT
@@ -1,5 +1,15 @@
1
1
  import { StyleProp, ViewStyle } from "react-native";
2
- import type { AxisEvent, ButtonEvent, DpadEvent, StatusEvent } from "./types";
2
+ import type { AxisEvent, ButtonEvent, DpadEvent, InfoEvent, StateEvent, StatusEvent } from "./types";
3
+ type VibrationRequest = {
4
+ type: "once";
5
+ duration: number;
6
+ strong: number;
7
+ weak: number;
8
+ nonce: number;
9
+ } | {
10
+ type: "stop";
11
+ nonce: number;
12
+ };
3
13
  type Props = {
4
14
  enabled?: boolean;
5
15
  axisThreshold?: number;
@@ -7,7 +17,10 @@ type Props = {
7
17
  onButton?: (event: ButtonEvent) => void;
8
18
  onAxis?: (event: AxisEvent) => void;
9
19
  onStatus?: (event: StatusEvent) => void;
20
+ onInfo?: (event: InfoEvent) => void;
21
+ onState?: (event: StateEvent) => void;
22
+ vibrationRequest?: VibrationRequest;
10
23
  style?: StyleProp<ViewStyle>;
11
24
  };
12
- export default function GamepadBridge({ enabled, axisThreshold, onDpad, onButton, onAxis, onStatus, style, }: Props): import("react/jsx-runtime").JSX.Element | null;
25
+ export default function GamepadBridge({ enabled, axisThreshold, onDpad, onButton, onAxis, onStatus, onInfo, onState, vibrationRequest, style, }: Props): import("react/jsx-runtime").JSX.Element | null;
13
26
  export {};
@@ -16,18 +16,87 @@ const buildBridgeHtml = (axisThreshold) => `
16
16
  (function(){
17
17
  const deadzone = ${axisThreshold.toFixed(2)};
18
18
  const send = (data) => window.ReactNativeWebView.postMessage(JSON.stringify(data));
19
- const buttonNames = ['a','b','x','y','lb','rb','lt','rt','back','start','ls','rs','dpadUp','dpadDown','dpadLeft','dpadRight','home'];
19
+ const buttonNames = ['a','b','x','y','lb','rb','lt','rt','back','start','ls','rs','dpadUp','dpadDown','dpadLeft','dpadRight','home','touchpad'];
20
20
  const axisNames = ['leftX','leftY','rightX','rightY'];
21
21
  let prevButtons = [];
22
22
  let prevAxes = [];
23
+ let prevInfoJson = '';
24
+
25
+ function parseVendorProduct(id){
26
+ const vendorMatch = /Vendor:\s?([0-9a-fA-F]+)/i.exec(id || '') || /VID_([0-9a-fA-F]+)/i.exec(id || '');
27
+ const productMatch = /Product:\s?([0-9a-fA-F]+)/i.exec(id || '') || /PID_([0-9a-fA-F]+)/i.exec(id || '');
28
+ return {
29
+ vendor: vendorMatch ? vendorMatch[1] : null,
30
+ product: productMatch ? productMatch[1] : null,
31
+ };
32
+ }
33
+
34
+ function sendInfo(gp){
35
+ const connected = !!gp;
36
+ const { vendor, product } = parseVendorProduct(gp?.id || '');
37
+ const info = {
38
+ type: 'info',
39
+ connected,
40
+ index: gp?.index ?? null,
41
+ id: gp?.id ?? null,
42
+ mapping: gp?.mapping ?? null,
43
+ timestamp: gp?.timestamp ?? null,
44
+ canVibrate: !!gp?.vibrationActuator,
45
+ vendor,
46
+ product,
47
+ axes: gp?.axes?.length ?? 0,
48
+ buttons: gp?.buttons?.length ?? 0,
49
+ };
50
+ const nextJson = JSON.stringify(info);
51
+ if (nextJson !== prevInfoJson) {
52
+ prevInfoJson = nextJson;
53
+ send(info);
54
+ }
55
+ }
56
+
57
+ function vibrateOnce(duration, strong, weak){
58
+ const pad = (navigator.getGamepads && navigator.getGamepads()[0]) || null;
59
+ if (!pad || !pad.vibrationActuator || !pad.vibrationActuator.playEffect) return;
60
+ try {
61
+ pad.vibrationActuator.playEffect('dual-rumble', {
62
+ duration: duration || 500,
63
+ strongMagnitude: strong ?? 1,
64
+ weakMagnitude: weak ?? 1,
65
+ }).catch(() => {});
66
+ } catch (err) {
67
+ // ignore
68
+ }
69
+ }
70
+
71
+ function stopVibration(){
72
+ const pad = (navigator.getGamepads && navigator.getGamepads()[0]) || null;
73
+ if (!pad || !pad.vibrationActuator) return;
74
+ if (typeof pad.vibrationActuator.reset === 'function') {
75
+ try { pad.vibrationActuator.reset(); } catch (err) {}
76
+ }
77
+ }
78
+
79
+ window.__earlVibrateOnce = vibrateOnce;
80
+ window.__earlStopVibration = stopVibration;
81
+
82
+ const nameForIndex = (index) => {
83
+ if (buttonNames[index]) return buttonNames[index];
84
+ if (index === 16) return 'home';
85
+ if (index === 17) return 'touchpad';
86
+ return 'button-' + index;
87
+ };
23
88
 
24
89
  function poll(){
25
90
  const pads = (navigator.getGamepads && navigator.getGamepads()) || [];
26
91
  const gp = pads[0];
92
+ sendInfo(gp);
27
93
  if (gp) {
94
+ const pressedNow = [];
95
+ const valueMap = {};
96
+ const axesState = {};
28
97
  // Buttons
29
98
  gp.buttons?.forEach((btn, index) => {
30
- const name = buttonNames[index] || ('button-' + index);
99
+ const name = nameForIndex(index);
31
100
  const prevBtn = prevButtons[index] || { pressed:false, value:0 };
32
101
  const changed = prevBtn.pressed !== btn.pressed || Math.abs(prevBtn.value - btn.value) > 0.01;
33
102
  if (changed) {
@@ -38,6 +107,8 @@ const buildBridgeHtml = (axisThreshold) => `
38
107
  if (index === 14) send({ type:'dpad', key:'left', pressed: !!btn.pressed });
39
108
  if (index === 15) send({ type:'dpad', key:'right', pressed: !!btn.pressed });
40
109
  }
110
+ if (btn?.pressed) pressedNow.push(name);
111
+ valueMap[name] = btn?.value ?? 0;
41
112
  prevButtons[index] = { pressed: !!btn.pressed, value: btn.value ?? 0 };
42
113
  });
43
114
 
@@ -49,13 +120,18 @@ const buildBridgeHtml = (axisThreshold) => `
49
120
  if (Math.abs(prevVal - value) > 0.01) {
50
121
  send({ type:'axis', axis:name, index, value });
51
122
  }
123
+ axesState[name] = value;
52
124
  prevAxes[index] = value;
53
125
  });
126
+
127
+ send({ type:'state', pressed: pressedNow, values: valueMap, axes: axesState });
54
128
  } else {
129
+ // If no pad, send a single disconnected info payload
130
+ sendInfo(null);
55
131
  if (prevButtons.length) {
56
132
  prevButtons.forEach((btn, index) => {
57
133
  if (btn?.pressed) {
58
- const name = buttonNames[index] || ('button-' + index);
134
+ const name = nameForIndex(index);
59
135
  send({ type:'button', button:name, index, pressed:false, value:0 });
60
136
  if (index === 12) send({ type:'dpad', key:'up', pressed:false });
61
137
  if (index === 13) send({ type:'dpad', key:'down', pressed:false });
@@ -74,6 +150,7 @@ const buildBridgeHtml = (axisThreshold) => `
74
150
  });
75
151
  prevAxes = [];
76
152
  }
153
+ send({ type:'state', pressed: [], values: {}, axes: {} });
77
154
  }
78
155
  requestAnimationFrame(poll);
79
156
  }
@@ -99,7 +176,7 @@ const buildBridgeHtml = (axisThreshold) => `
99
176
  })();
100
177
  </script>
101
178
  </body></html>`;
102
- function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton, onAxis, onStatus, style, }) {
179
+ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton, onAxis, onStatus, onInfo, onState, vibrationRequest, style, }) {
103
180
  const webviewRef = (0, react_1.useRef)(null);
104
181
  const html = (0, react_1.useMemo)(() => buildBridgeHtml(axisThreshold), [axisThreshold]);
105
182
  const focusBridge = (0, react_1.useCallback)(() => {
@@ -113,6 +190,19 @@ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton,
113
190
  if (enabled)
114
191
  focusBridge();
115
192
  }, [enabled, focusBridge]);
193
+ (0, react_1.useEffect)(() => {
194
+ if (!enabled || !vibrationRequest)
195
+ return;
196
+ const node = webviewRef.current;
197
+ if (!node)
198
+ return;
199
+ if (vibrationRequest.type === "once") {
200
+ node.injectJavaScript(`window.__earlVibrateOnce(${Math.max(vibrationRequest.duration, 0)}, ${Math.max(Math.min(vibrationRequest.strong, 1), 0)}, ${Math.max(Math.min(vibrationRequest.weak, 1), 0)}); true;`);
201
+ }
202
+ else {
203
+ node.injectJavaScript(`window.__earlStopVibration(); true;`);
204
+ }
205
+ }, [enabled, vibrationRequest]);
116
206
  if (!enabled)
117
207
  return null;
118
208
  const handleMessage = (event) => {
@@ -130,6 +220,12 @@ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton,
130
220
  else if (data.type === "status") {
131
221
  onStatus === null || onStatus === void 0 ? void 0 : onStatus(data);
132
222
  }
223
+ else if (data.type === "info") {
224
+ onInfo === null || onInfo === void 0 ? void 0 : onInfo(data);
225
+ }
226
+ else if (data.type === "state") {
227
+ onState === null || onState === void 0 ? void 0 : onState(data);
228
+ }
133
229
  }
134
230
  catch {
135
231
  // ignore malformed messages
@@ -15,6 +15,11 @@ function ControllerVisual({ pressed, axis, value }) {
15
15
  const v = value(name);
16
16
  return Math.max(v, pressed(name) ? 1 : 0);
17
17
  };
18
+ const { width, height } = (0, react_native_1.useWindowDimensions)();
19
+ const isPortrait = height >= width;
20
+ const containerScaleStyle = isPortrait
21
+ ? { transform: [{ scale: 0.7 }] }
22
+ : undefined;
18
23
  const mix = (a, b, t) => Math.round(a + (b - a) * t);
19
24
  const stickColor = (mag) => {
20
25
  const clamped = Math.min(1, Math.max(0, mag));
@@ -30,7 +35,7 @@ function ControllerVisual({ pressed, axis, value }) {
30
35
  };
31
36
  const leftMag = Math.min(1, Math.hypot(axis("leftX"), axis("leftY")));
32
37
  const rightMag = Math.min(1, Math.hypot(axis("rightX"), axis("rightY")));
33
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psWrapper, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.psContainer, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.psShoulderRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
38
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psWrapper, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.psContainer, containerScaleStyle], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.psShoulderRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
34
39
  styles.psShoulder,
35
40
  styles.psShoulderLeft,
36
41
  level("lb") > 0 && styles.psShoulderActive,
@@ -62,7 +67,10 @@ function ControllerVisual({ pressed, axis, value }) {
62
67
  ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
63
68
  styles.psShoulderText,
64
69
  level("rt") > 0 && styles.psShoulderTextActive,
65
- ], children: "R2" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psMiddle }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psPaveTactile }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
70
+ ], children: "R2" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psMiddle }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
71
+ styles.psPaveTactile,
72
+ pressed("touchpad") && styles.psPaveTactileActive,
73
+ ] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
66
74
  styles.psShare,
67
75
  pressed("back") && styles.psActiveButton,
68
76
  ] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
@@ -121,7 +129,7 @@ function ControllerVisual({ pressed, axis, value }) {
121
129
  {
122
130
  transform: [
123
131
  { translateX: axis("leftX") * 6 },
124
- { translateY: axis("leftY") * -6 },
132
+ { translateY: axis("leftY") * 6 },
125
133
  ],
126
134
  backgroundColor: stickColor(leftMag),
127
135
  },
@@ -142,7 +150,7 @@ function ControllerVisual({ pressed, axis, value }) {
142
150
  {
143
151
  transform: [
144
152
  { translateX: axis("rightX") * 6 },
145
- { translateY: axis("rightY") * -6 },
153
+ { translateY: axis("rightY") * 6 },
146
154
  ],
147
155
  backgroundColor: stickColor(rightMag),
148
156
  },
@@ -155,7 +163,8 @@ function ControllerVisual({ pressed, axis, value }) {
155
163
  ] }) }) })] })] }) }));
156
164
  }
157
165
  function GamepadDebug({ enabled = true, axisThreshold = 0.15, }) {
158
- const { bridge, pressedButtons, axes, buttonValues } = (0, useGamepad_1.default)({
166
+ var _a, _b, _c, _d;
167
+ const { bridge, pressedButtons, axes, buttonValues, info, vibrate, stopVibration, } = (0, useGamepad_1.default)({
159
168
  enabled,
160
169
  axisThreshold,
161
170
  });
@@ -163,7 +172,59 @@ function GamepadDebug({ enabled = true, axisThreshold = 0.15, }) {
163
172
  const pressed = (key) => pressedButtons.has(key);
164
173
  const axisValue = (key) => { var _a; return (_a = axes[key]) !== null && _a !== void 0 ? _a : 0; };
165
174
  const buttonValue = (key) => { var _a; return (_a = buttonValues[key]) !== null && _a !== void 0 ? _a : 0; };
166
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.container, children: (0, jsx_runtime_1.jsxs)(react_native_1.ScrollView, { contentContainerStyle: styles.scrollContent, showsVerticalScrollIndicator: false, children: [bridge, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.body, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.controllerCard], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.cardTitle, children: "Controller" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.controller, children: (0, jsx_runtime_1.jsx)(ControllerVisual, { pressed: pressed, axis: axisValue, value: buttonValue }) })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.stateCard], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.cardTitle, children: "State" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sectionTitle, children: "Pressed" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.badgeRow, children: pressedList.length === 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.muted, children: "None" })) : (pressedList.map((name) => ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.badge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: name }) }, name)))) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sectionTitle, children: "Axes" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.axesGrid, children: trackedAxes.map((axisName) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.axisItem, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.axisLabel, children: axisName }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.axisValue, children: format(axes[axisName]) })] }, axisName))) })] })] })] }) }));
175
+ const touchpadPressed = pressed("touchpad");
176
+ const renderStickCard = (title, xLabel, yLabel, x, y) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickTitle, children: title }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickContent, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickAxesList, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickAxisRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisLabel, children: xLabel }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisValue, children: format(x) })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickAxisRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisLabel, children: yLabel }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisValue, children: format(y) })] })] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickPlot, children: (() => {
177
+ const radius = 44;
178
+ const dotX = x * radius;
179
+ const dotY = y * radius;
180
+ const distance = Math.hypot(dotX, dotY);
181
+ const hasMovement = distance > 1;
182
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickCircle, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickCrosshairH }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickCrosshairV }), hasMovement && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
183
+ styles.stickGuideDiag,
184
+ {
185
+ width: distance,
186
+ left: 55,
187
+ top: 55,
188
+ transform: [
189
+ {
190
+ rotate: `${Math.atan2(dotY, dotX) *
191
+ (180 / Math.PI)}deg`,
192
+ },
193
+ ],
194
+ },
195
+ ] })), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
196
+ styles.stickDot,
197
+ {
198
+ transform: [
199
+ { translateX: dotX },
200
+ { translateY: dotY },
201
+ ],
202
+ },
203
+ ] })] }));
204
+ })() })] })] }));
205
+ const isConnected = info.connected;
206
+ const controllerLabel = (_a = info.id) !== null && _a !== void 0 ? _a : "No controller detected";
207
+ const vendorLabel = (_b = info.vendor) !== null && _b !== void 0 ? _b : "—";
208
+ const productLabel = (_c = info.product) !== null && _c !== void 0 ? _c : "—";
209
+ const mappingLabel = (_d = info.mapping) !== null && _d !== void 0 ? _d : "unknown";
210
+ const timestampLabel = info.timestamp != null ? Math.round(info.timestamp).toString() : "—";
211
+ const infoItems = [
212
+ { label: "Name", value: controllerLabel },
213
+ { label: "Vendor", value: vendorLabel },
214
+ { label: "Product", value: productLabel },
215
+ { label: "Mapping", value: mappingLabel },
216
+ { label: "Index", value: info.index != null ? `${info.index}` : "—" },
217
+ { label: "Axes", value: `${info.axes || 0}` },
218
+ { label: "Buttons", value: `${info.buttons || 0}` },
219
+ { label: "Vibration", value: info.canVibrate ? "Yes" : "No" },
220
+ { label: "Timestamp", value: timestampLabel },
221
+ ];
222
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.container, children: (0, jsx_runtime_1.jsxs)(react_native_1.ScrollView, { contentContainerStyle: styles.scrollContent, showsVerticalScrollIndicator: false, children: [bridge, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.body, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.controllerCard], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.statusRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.cardTitle, children: "Controller" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
223
+ styles.statusPill,
224
+ isConnected
225
+ ? styles.statusPillConnected
226
+ : styles.statusPillWaiting,
227
+ ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.statusPillText, children: isConnected ? "Connected" : "Waiting" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperText, children: "Connect your gamepad and press a button to test." }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.infoGrid, children: infoItems.map(({ label, value }) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.infoItem, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.infoLabel, children: label }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.infoValue, children: value })] }, label))) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.controller, children: [!isConnected && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loaderOverlay, children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { color: "#2563eb", size: "small" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loaderText, children: "Connect your gamepad and press a button to test..." })] })), (0, jsx_runtime_1.jsx)(ControllerVisual, { pressed: pressed, axis: axisValue, value: buttonValue })] }), info.canVibrate && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.testsRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.button, onPress: () => vibrate(900, 1), children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.buttonText, children: "Vibration, 1 sec" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.button, onPress: stopVibration, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.buttonText, children: "Stop vibration" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperTextSmall, children: "Uses vibrationActuator when available." })] })), !info.canVibrate && ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperTextSmall, children: "Vibration not reported by this controller." }))] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.stateCard], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.cardTitle, children: "State" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sectionTitle, children: "Pressed" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.badgeRow, children: pressedList.length === 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.muted, children: "None" })) : (pressedList.map((name) => ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.badge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: name }) }, name)))) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sectionTitle, children: "Axes" }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickGrid, children: [renderStickCard("L STICK", "Axis 0 (leftX)", "Axis 1 (leftY)", axisValue("leftX"), axisValue("leftY")), renderStickCard("R STICK", "Axis 2 (rightX)", "Axis 3 (rightY)", axisValue("rightX"), axisValue("rightY"))] })] })] })] }) }));
167
228
  }
168
229
  const styles = react_native_1.StyleSheet.create({
169
230
  container: {
@@ -252,6 +313,106 @@ const styles = react_native_1.StyleSheet.create({
252
313
  fontSize: 16,
253
314
  fontVariant: ["tabular-nums"],
254
315
  },
316
+ stickGrid: {
317
+ flexDirection: "row",
318
+ flexWrap: "wrap",
319
+ gap: 10,
320
+ },
321
+ stickCard: {
322
+ flexGrow: 1,
323
+ flexBasis: "48%",
324
+ minWidth: 240,
325
+ backgroundColor: "#f8fafc",
326
+ borderWidth: 1,
327
+ borderColor: "#cbd5e1",
328
+ borderRadius: 10,
329
+ padding: 10,
330
+ gap: 10,
331
+ },
332
+ stickTitle: {
333
+ color: "#475569",
334
+ fontSize: 12,
335
+ fontWeight: "700",
336
+ letterSpacing: 0.3,
337
+ },
338
+ stickContent: {
339
+ flexDirection: "row",
340
+ justifyContent: "space-between",
341
+ alignItems: "center",
342
+ gap: 12,
343
+ },
344
+ stickAxesList: {
345
+ gap: 6,
346
+ },
347
+ stickAxisRow: {
348
+ flexDirection: "row",
349
+ justifyContent: "space-between",
350
+ alignItems: "center",
351
+ gap: 8,
352
+ },
353
+ stickAxisLabel: {
354
+ color: "#475569",
355
+ fontSize: 12,
356
+ fontWeight: "600",
357
+ },
358
+ stickAxisValue: {
359
+ color: "#0f172a",
360
+ fontSize: 16,
361
+ fontWeight: "700",
362
+ fontVariant: ["tabular-nums"],
363
+ },
364
+ stickPlot: {
365
+ width: 120,
366
+ height: 120,
367
+ alignItems: "center",
368
+ justifyContent: "center",
369
+ },
370
+ stickCircle: {
371
+ width: 110,
372
+ height: 110,
373
+ borderRadius: 55,
374
+ borderWidth: 1,
375
+ borderColor: "#cbd5e1",
376
+ backgroundColor: "#fff",
377
+ alignItems: "center",
378
+ justifyContent: "center",
379
+ position: "relative",
380
+ },
381
+ stickCrosshairH: {
382
+ position: "absolute",
383
+ left: 5,
384
+ right: 5,
385
+ top: 55,
386
+ height: 1,
387
+ backgroundColor: "#e2e8f0",
388
+ },
389
+ stickCrosshairV: {
390
+ position: "absolute",
391
+ top: 5,
392
+ bottom: 5,
393
+ left: 55,
394
+ width: 1,
395
+ backgroundColor: "#e2e8f0",
396
+ },
397
+ stickGuideDiag: {
398
+ position: "absolute",
399
+ height: 0,
400
+ borderTopWidth: 1,
401
+ borderColor: "#94a3b8",
402
+ borderStyle: "dashed",
403
+ transformOrigin: "left center",
404
+ },
405
+ stickDot: {
406
+ width: 10,
407
+ height: 10,
408
+ borderRadius: 5,
409
+ backgroundColor: "#1d4ed8",
410
+ position: "absolute",
411
+ top: 55,
412
+ left: 55,
413
+ marginLeft: -5,
414
+ marginTop: -5,
415
+ },
255
416
  body: {
256
417
  flexDirection: "row",
257
418
  flexWrap: "wrap",
@@ -279,6 +440,65 @@ const styles = react_native_1.StyleSheet.create({
279
440
  fontWeight: "700",
280
441
  fontSize: 16,
281
442
  },
443
+ statusRow: {
444
+ flexDirection: "row",
445
+ alignItems: "center",
446
+ justifyContent: "space-between",
447
+ gap: 8,
448
+ },
449
+ statusPill: {
450
+ paddingHorizontal: 10,
451
+ paddingVertical: 4,
452
+ borderRadius: 999,
453
+ borderWidth: 1,
454
+ },
455
+ statusPillConnected: {
456
+ backgroundColor: "#dcfce7",
457
+ borderColor: "#16a34a",
458
+ },
459
+ statusPillWaiting: {
460
+ backgroundColor: "#fee2e2",
461
+ borderColor: "#f97316",
462
+ },
463
+ statusPillText: {
464
+ fontSize: 12,
465
+ fontWeight: "700",
466
+ color: "#0f172a",
467
+ },
468
+ helperText: {
469
+ color: "#475569",
470
+ fontSize: 12,
471
+ },
472
+ helperTextSmall: {
473
+ color: "#94a3b8",
474
+ fontSize: 11,
475
+ },
476
+ infoGrid: {
477
+ flexDirection: "row",
478
+ flexWrap: "wrap",
479
+ gap: 8,
480
+ },
481
+ infoItem: {
482
+ flexGrow: 1,
483
+ flexBasis: "45%",
484
+ minWidth: 140,
485
+ backgroundColor: "#f8fafc",
486
+ borderWidth: 1,
487
+ borderColor: "#e2e8f0",
488
+ borderRadius: 8,
489
+ padding: 8,
490
+ gap: 2,
491
+ },
492
+ infoLabel: {
493
+ color: "#475569",
494
+ fontSize: 11,
495
+ fontWeight: "700",
496
+ },
497
+ infoValue: {
498
+ color: "#0f172a",
499
+ fontSize: 13,
500
+ fontWeight: "600",
501
+ },
282
502
  controller: {
283
503
  backgroundColor: "#f8fafc",
284
504
  borderWidth: 1,
@@ -286,6 +506,50 @@ const styles = react_native_1.StyleSheet.create({
286
506
  padding: 12,
287
507
  alignItems: "center",
288
508
  justifyContent: "center",
509
+ position: "relative",
510
+ overflow: "hidden",
511
+ },
512
+ loaderOverlay: {
513
+ position: "absolute",
514
+ top: 0,
515
+ left: 0,
516
+ right: 0,
517
+ bottom: 0,
518
+ backgroundColor: "rgba(255,255,255,0.92)",
519
+ alignItems: "center",
520
+ justifyContent: "center",
521
+ padding: 12,
522
+ gap: 6,
523
+ zIndex: 50,
524
+ },
525
+ loaderText: {
526
+ color: "#0f172a",
527
+ fontSize: 12,
528
+ textAlign: "center",
529
+ },
530
+ testsRow: {
531
+ flexDirection: "row",
532
+ flexWrap: "wrap",
533
+ gap: 8,
534
+ },
535
+ button: {
536
+ backgroundColor: "#2563eb",
537
+ paddingVertical: 10,
538
+ paddingHorizontal: 12,
539
+ borderRadius: 8,
540
+ alignItems: "center",
541
+ justifyContent: "center",
542
+ },
543
+ buttonDisabled: {
544
+ backgroundColor: "#cbd5e1",
545
+ },
546
+ buttonText: {
547
+ color: "#f8fafc",
548
+ fontWeight: "700",
549
+ fontSize: 13,
550
+ },
551
+ buttonTextDisabled: {
552
+ color: "#475569",
289
553
  },
290
554
  axesGrid: {
291
555
  flexDirection: "row",
@@ -349,7 +613,7 @@ const styles = react_native_1.StyleSheet.create({
349
613
  width: 395,
350
614
  height: 160,
351
615
  backgroundColor: "#e0e0e0",
352
- marginLeft: 27,
616
+ marginLeft: 50, // 27
353
617
  borderRadius: 25,
354
618
  position: "absolute",
355
619
  zIndex: 0,
@@ -359,7 +623,7 @@ const styles = react_native_1.StyleSheet.create({
359
623
  width: 150,
360
624
  height: 80,
361
625
  backgroundColor: "#333",
362
- marginLeft: 147,
626
+ marginLeft: 170,
363
627
  borderRadius: 7,
364
628
  position: "absolute",
365
629
  zIndex: 10,
@@ -370,12 +634,18 @@ const styles = react_native_1.StyleSheet.create({
370
634
  shadowRadius: 10,
371
635
  elevation: 5,
372
636
  },
637
+ psPaveTactileActive: {
638
+ backgroundColor: "#2563eb",
639
+ shadowColor: "#2563eb",
640
+ shadowOpacity: 0.65,
641
+ shadowRadius: 12,
642
+ },
373
643
  psShare: {
374
644
  width: 12,
375
645
  height: 25,
376
646
  position: "absolute",
377
647
  backgroundColor: "#95a5a6",
378
- marginLeft: 125,
648
+ marginLeft: 148,
379
649
  marginTop: 85,
380
650
  borderRadius: 5,
381
651
  zIndex: 10,
@@ -390,7 +660,7 @@ const styles = react_native_1.StyleSheet.create({
390
660
  height: 25,
391
661
  position: "absolute",
392
662
  backgroundColor: "#95a5a6",
393
- marginLeft: 305,
663
+ marginLeft: 327,
394
664
  marginTop: 85,
395
665
  borderRadius: 5,
396
666
  zIndex: 10,
@@ -405,7 +675,7 @@ const styles = react_native_1.StyleSheet.create({
405
675
  height: 260,
406
676
  backgroundColor: "#e0e0e0",
407
677
  position: "absolute",
408
- left: 0,
678
+ left: 23,
409
679
  top: 95,
410
680
  transform: [{ rotate: "11deg" }],
411
681
  borderTopLeftRadius: 30,
@@ -426,7 +696,7 @@ const styles = react_native_1.StyleSheet.create({
426
696
  height: 260,
427
697
  backgroundColor: "#e0e0e0",
428
698
  position: "absolute",
429
- left: 330,
699
+ left: 353,
430
700
  top: 95,
431
701
  transform: [{ rotate: "-11deg" }],
432
702
  borderTopLeftRadius: 30,
@@ -448,7 +718,7 @@ const styles = react_native_1.StyleSheet.create({
448
718
  height: 112,
449
719
  borderRadius: 56,
450
720
  marginTop: 5,
451
- marginLeft: 10,
721
+ marginLeft: 0,
452
722
  borderWidth: 1,
453
723
  borderColor: "#b0b0b0",
454
724
  position: "relative",
@@ -514,7 +784,7 @@ const styles = react_native_1.StyleSheet.create({
514
784
  width: 112,
515
785
  height: 112,
516
786
  borderRadius: 56,
517
- marginTop: 2,
787
+ marginTop: 4,
518
788
  borderWidth: 1,
519
789
  borderColor: "#b0b0b0",
520
790
  position: "relative",
package/dist/types.d.ts CHANGED
@@ -1,26 +1,47 @@
1
- export type DpadKey = 'up' | 'down' | 'left' | 'right';
2
- export type GamepadButtonName = 'a' | 'b' | 'x' | 'y' | 'lb' | 'rb' | 'lt' | 'rt' | 'back' | 'start' | 'ls' | 'rs' | 'dpadUp' | 'dpadDown' | 'dpadLeft' | 'dpadRight' | 'home' | `button-${number}`;
3
- export type StickAxisName = 'leftX' | 'leftY' | 'rightX' | 'rightY' | `axis-${number}`;
1
+ export type DpadKey = "up" | "down" | "left" | "right";
2
+ export type GamepadButtonName = "a" | "b" | "x" | "y" | "lb" | "rb" | "lt" | "rt" | "back" | "start" | "ls" | "rs" | "dpadUp" | "dpadDown" | "dpadLeft" | "dpadRight" | "home" | "touchpad" | `button-${number}`;
3
+ export type StickAxisName = "leftX" | "leftY" | "rightX" | "rightY" | `axis-${number}`;
4
4
  export type DpadEvent = {
5
- type: 'dpad';
5
+ type: "dpad";
6
6
  key: DpadKey;
7
7
  pressed: boolean;
8
8
  };
9
9
  export type ButtonEvent = {
10
- type: 'button';
10
+ type: "button";
11
11
  button: GamepadButtonName;
12
12
  index: number;
13
13
  pressed: boolean;
14
14
  value: number;
15
15
  };
16
16
  export type AxisEvent = {
17
- type: 'axis';
17
+ type: "axis";
18
18
  axis: StickAxisName;
19
19
  index: number;
20
20
  value: number;
21
21
  };
22
+ export type GamepadInfo = {
23
+ connected: boolean;
24
+ index: number | null;
25
+ id: string | null;
26
+ mapping: string | null;
27
+ timestamp: number | null;
28
+ canVibrate: boolean;
29
+ vendor: string | null;
30
+ product: string | null;
31
+ axes: number;
32
+ buttons: number;
33
+ };
34
+ export type InfoEvent = GamepadInfo & {
35
+ type: "info";
36
+ };
37
+ export type StateEvent = {
38
+ type: "state";
39
+ pressed: GamepadButtonName[];
40
+ values: Partial<Record<GamepadButtonName, number>>;
41
+ axes: Partial<Record<StickAxisName, number>>;
42
+ };
22
43
  export type StatusEvent = {
23
- type: 'status';
24
- state: 'connected' | 'disconnected';
44
+ type: "status";
45
+ state: "connected" | "disconnected";
25
46
  };
26
- export type GamepadMessage = DpadEvent | ButtonEvent | AxisEvent | StatusEvent;
47
+ export type GamepadMessage = DpadEvent | ButtonEvent | AxisEvent | StatusEvent | InfoEvent | StateEvent;
@@ -1,5 +1,5 @@
1
1
  import { type JSX } from "react";
2
- import type { AxisEvent, ButtonEvent, DpadEvent, GamepadButtonName, StickAxisName, StatusEvent } from "./types";
2
+ import type { AxisEvent, ButtonEvent, DpadEvent, GamepadButtonName, GamepadInfo, StickAxisName, InfoEvent, StatusEvent } from "./types";
3
3
  type Options = {
4
4
  enabled?: boolean;
5
5
  axisThreshold?: number;
@@ -7,6 +7,7 @@ type Options = {
7
7
  onAxis?: (event: AxisEvent) => void;
8
8
  onDpad?: (event: DpadEvent) => void;
9
9
  onStatus?: (event: StatusEvent) => void;
10
+ onInfo?: (event: InfoEvent) => void;
10
11
  };
11
12
  type Return = {
12
13
  pressedButtons: Set<GamepadButtonName>;
@@ -14,6 +15,9 @@ type Return = {
14
15
  buttonValues: Partial<Record<GamepadButtonName, number>>;
15
16
  isPressed: (key: GamepadButtonName) => boolean;
16
17
  bridge: JSX.Element | null;
18
+ info: GamepadInfo;
19
+ vibrate: (duration?: number, strength?: number) => void;
20
+ stopVibration: () => void;
17
21
  };
18
- export default function useGamepad({ enabled, axisThreshold, onButton, onAxis, onDpad, onStatus, }?: Options): Return;
22
+ export default function useGamepad({ enabled, axisThreshold, onButton, onAxis, onDpad, onStatus, onInfo, }?: Options): Return;
19
23
  export {};
@@ -7,7 +7,7 @@ exports.default = useGamepad;
7
7
  const jsx_runtime_1 = require("react/jsx-runtime");
8
8
  const react_1 = require("react");
9
9
  const GamepadBridge_1 = __importDefault(require("./GamepadBridge"));
10
- function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, onDpad, onStatus, } = {}) {
10
+ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, onDpad, onStatus, onInfo, } = {}) {
11
11
  const [pressedButtons, setPressedButtons] = (0, react_1.useState)(new Set());
12
12
  const [buttonValues, setButtonValues] = (0, react_1.useState)({});
13
13
  const pressedRef = (0, react_1.useRef)(new Set());
@@ -17,6 +17,27 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
17
17
  rightX: 0,
18
18
  rightY: 0,
19
19
  });
20
+ const [info, setInfo] = (0, react_1.useState)({
21
+ connected: false,
22
+ index: null,
23
+ id: null,
24
+ mapping: null,
25
+ timestamp: null,
26
+ canVibrate: false,
27
+ vendor: null,
28
+ product: null,
29
+ axes: 0,
30
+ buttons: 0,
31
+ });
32
+ const [vibrationRequest, setVibrationRequest] = (0, react_1.useState)(null);
33
+ const vibrationNonce = (0, react_1.useRef)(0);
34
+ const resetState = (0, react_1.useCallback)(() => {
35
+ const cleared = new Set();
36
+ pressedRef.current = cleared;
37
+ setPressedButtons(cleared);
38
+ setButtonValues({});
39
+ setAxes({ leftX: 0, leftY: 0, rightX: 0, rightY: 0 });
40
+ }, []);
20
41
  (0, react_1.useEffect)(() => {
21
42
  pressedRef.current = pressedButtons;
22
43
  }, [pressedButtons]);
@@ -43,17 +64,75 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
43
64
  const handleDpad = (0, react_1.useCallback)((event) => {
44
65
  onDpad === null || onDpad === void 0 ? void 0 : onDpad(event);
45
66
  }, [onDpad]);
46
- (0, react_1.useEffect)(() => {
47
- if (!enabled && pressedRef.current.size) {
48
- pressedRef.current = new Set();
49
- setPressedButtons(new Set());
67
+ const handleInfo = (0, react_1.useCallback)((event) => {
68
+ if (!event.connected) {
69
+ resetState();
70
+ }
71
+ setInfo(event);
72
+ onInfo === null || onInfo === void 0 ? void 0 : onInfo(event);
73
+ }, [onInfo, resetState]);
74
+ const handleState = (0, react_1.useCallback)((event) => {
75
+ var _a, _b;
76
+ const next = new Set(event.pressed);
77
+ pressedRef.current = next;
78
+ setPressedButtons(next);
79
+ setButtonValues((_a = event.values) !== null && _a !== void 0 ? _a : {});
80
+ setAxes((_b = event.axes) !== null && _b !== void 0 ? _b : {});
81
+ }, []);
82
+ const handleStatus = (0, react_1.useCallback)((event) => {
83
+ if (event.state === "disconnected") {
84
+ resetState();
85
+ setInfo((prev) => ({
86
+ ...prev,
87
+ connected: false,
88
+ }));
50
89
  }
90
+ onStatus === null || onStatus === void 0 ? void 0 : onStatus(event);
91
+ }, [onStatus, resetState]);
92
+ const vibrate = (0, react_1.useCallback)((duration = 800, strength = 1) => {
93
+ vibrationNonce.current += 1;
94
+ const safeStrength = Math.max(0, Math.min(strength, 1));
95
+ setVibrationRequest({
96
+ type: "once",
97
+ duration,
98
+ strong: safeStrength,
99
+ weak: safeStrength,
100
+ nonce: vibrationNonce.current,
101
+ });
102
+ }, []);
103
+ const stopVibration = (0, react_1.useCallback)(() => {
104
+ vibrationNonce.current += 1;
105
+ setVibrationRequest({ type: "stop", nonce: vibrationNonce.current });
106
+ }, []);
107
+ (0, react_1.useEffect)(() => {
51
108
  if (!enabled) {
52
- setAxes({ leftX: 0, leftY: 0, rightX: 0, rightY: 0 });
53
- setButtonValues({});
109
+ resetState();
110
+ setInfo((prev) => ({
111
+ ...prev,
112
+ connected: false,
113
+ }));
54
114
  }
55
- }, [enabled]);
56
- const bridge = (0, react_1.useMemo)(() => ((0, jsx_runtime_1.jsx)(GamepadBridge_1.default, { enabled: enabled, axisThreshold: axisThreshold, onDpad: handleDpad, onButton: handleButton, onAxis: handleAxis, onStatus: onStatus })), [enabled, axisThreshold, handleAxis, handleButton, handleDpad, onStatus]);
115
+ }, [enabled, resetState]);
116
+ const bridge = (0, react_1.useMemo)(() => ((0, jsx_runtime_1.jsx)(GamepadBridge_1.default, { enabled: enabled, axisThreshold: axisThreshold, onDpad: handleDpad, onButton: handleButton, onAxis: handleAxis, onStatus: handleStatus, onInfo: handleInfo, onState: handleState, vibrationRequest: vibrationRequest !== null && vibrationRequest !== void 0 ? vibrationRequest : undefined })), [
117
+ enabled,
118
+ axisThreshold,
119
+ handleAxis,
120
+ handleButton,
121
+ handleDpad,
122
+ handleInfo,
123
+ handleStatus,
124
+ handleState,
125
+ vibrationRequest,
126
+ ]);
57
127
  const isPressed = (0, react_1.useCallback)((key) => pressedRef.current.has(key), []);
58
- return { pressedButtons, axes, buttonValues, isPressed, bridge };
128
+ return {
129
+ pressedButtons,
130
+ axes,
131
+ buttonValues,
132
+ isPressed,
133
+ bridge,
134
+ info,
135
+ vibrate,
136
+ stopVibration,
137
+ };
59
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-earl-gamepad",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "React Native gamepad bridge via WebView (buttons, sticks, d-pad, status).",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",