react-native-earl-gamepad 0.5.1 → 0.6.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,11 +1,24 @@
1
1
  # react-native-earl-gamepad
2
2
 
3
+ ![GitHub stars](https://img.shields.io/github/stars/Swif7ify/react-native-earl-gamepad?style=social)
4
+ ![npm](https://img.shields.io/npm/v/react-native-earl-gamepad)
5
+ ![downloads](https://img.shields.io/npm/dm/react-native-earl-gamepad)
6
+ ![license](https://img.shields.io/npm/l/react-native-earl-gamepad)
7
+
3
8
  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.
4
9
 
5
10
  - Components: `GamepadBridge`, `useGamepad`, and `GamepadDebug`.
6
11
  - Deadzone handling (default `0.15`) with auto-clear on disconnect.
7
12
  - Typed events for buttons, axes, d-pad, and status.
8
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.
21
+
9
22
  ## Requirements
10
23
 
11
24
  - React Native `>=0.72`
@@ -119,7 +132,7 @@ export function HUD() {
119
132
 
120
133
  ### Visual debugger
121
134
 
122
- 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.
123
136
 
124
137
  ```tsx
125
138
  import { GamepadDebug } from "react-native-earl-gamepad";
@@ -129,9 +142,9 @@ export function DebugScreen() {
129
142
  }
130
143
  ```
131
144
 
132
- ![Gamepad visual idle](https://github.com/user-attachments/assets/dfebd8c5-7d9a-42c7-802b-2773ec8c8ae9)
133
-
145
+ ![Gamepad visual idle](https://github.com/user-attachments/assets/9d4cbc94-c3aa-434a-99ae-5ea8b01b06e3)
134
146
  ![Gamepad visual pressed](https://github.com/user-attachments/assets/7b37d76a-7695-4be9-bda4-7e3d1e6adf41)
147
+ ![Gamepad loader](https://github.com/user-attachments/assets/5bb462e5-3259-4680-bfcf-005324ac2aec)
135
148
 
136
149
  ## API
137
150
 
@@ -160,6 +173,9 @@ Return shape:
160
173
  - `buttonValues: Partial<Record<GamepadButtonName, number>>` — last analog value per button (useful for LT/RT triggers).
161
174
  - `isPressed(key: GamepadButtonName): boolean` — helper to check a single button.
162
175
  - `bridge: JSX.Element | null` — render once to enable polling.
176
+ - `info: GamepadInfo` — metadata for the first controller (id, vendor/product if exposed, mapping, counts, vibration support, timestamp, index).
177
+ - `vibrate(duration?: number, strength?: number): void` — fire a short rumble when `vibrationActuator` is available.
178
+ - `stopVibration(): void` — stop an in-flight vibration when supported.
163
179
 
164
180
  ### `GamepadDebug`
165
181
 
@@ -197,13 +213,13 @@ npm install
197
213
  npm run build
198
214
  ```
199
215
 
216
+ Build outputs to `dist/` with type declarations.
217
+
200
218
  ## Troubleshooting
201
219
 
202
220
  - **[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.
203
221
  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`.
204
222
 
205
- Build outputs to `dist/` with type declarations.
206
-
207
223
  ## License
208
224
 
209
225
  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, 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,9 @@ 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
+ vibrationRequest?: VibrationRequest;
10
22
  style?: StyleProp<ViewStyle>;
11
23
  };
12
- export default function GamepadBridge({ enabled, axisThreshold, onDpad, onButton, onAxis, onStatus, style, }: Props): import("react/jsx-runtime").JSX.Element | null;
24
+ export default function GamepadBridge({ enabled, axisThreshold, onDpad, onButton, onAxis, onStatus, onInfo, vibrationRequest, style, }: Props): import("react/jsx-runtime").JSX.Element | null;
13
25
  export {};
@@ -20,10 +20,69 @@ const buildBridgeHtml = (axisThreshold) => `
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;
23
81
 
24
82
  function poll(){
25
83
  const pads = (navigator.getGamepads && navigator.getGamepads()) || [];
26
84
  const gp = pads[0];
85
+ sendInfo(gp);
27
86
  if (gp) {
28
87
  // Buttons
29
88
  gp.buttons?.forEach((btn, index) => {
@@ -52,6 +111,8 @@ const buildBridgeHtml = (axisThreshold) => `
52
111
  prevAxes[index] = value;
53
112
  });
54
113
  } else {
114
+ // If no pad, send a single disconnected info payload
115
+ sendInfo(null);
55
116
  if (prevButtons.length) {
56
117
  prevButtons.forEach((btn, index) => {
57
118
  if (btn?.pressed) {
@@ -99,7 +160,7 @@ const buildBridgeHtml = (axisThreshold) => `
99
160
  })();
100
161
  </script>
101
162
  </body></html>`;
102
- function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton, onAxis, onStatus, style, }) {
163
+ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton, onAxis, onStatus, onInfo, vibrationRequest, style, }) {
103
164
  const webviewRef = (0, react_1.useRef)(null);
104
165
  const html = (0, react_1.useMemo)(() => buildBridgeHtml(axisThreshold), [axisThreshold]);
105
166
  const focusBridge = (0, react_1.useCallback)(() => {
@@ -113,6 +174,19 @@ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton,
113
174
  if (enabled)
114
175
  focusBridge();
115
176
  }, [enabled, focusBridge]);
177
+ (0, react_1.useEffect)(() => {
178
+ if (!enabled || !vibrationRequest)
179
+ return;
180
+ const node = webviewRef.current;
181
+ if (!node)
182
+ return;
183
+ if (vibrationRequest.type === "once") {
184
+ 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;`);
185
+ }
186
+ else {
187
+ node.injectJavaScript(`window.__earlStopVibration(); true;`);
188
+ }
189
+ }, [enabled, vibrationRequest]);
116
190
  if (!enabled)
117
191
  return null;
118
192
  const handleMessage = (event) => {
@@ -130,6 +204,9 @@ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton,
130
204
  else if (data.type === "status") {
131
205
  onStatus === null || onStatus === void 0 ? void 0 : onStatus(data);
132
206
  }
207
+ else if (data.type === "info") {
208
+ onInfo === null || onInfo === void 0 ? void 0 : onInfo(data);
209
+ }
133
210
  }
134
211
  catch {
135
212
  // 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.8 }] }
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,
@@ -155,7 +160,8 @@ function ControllerVisual({ pressed, axis, value }) {
155
160
  ] }) }) })] })] }) }));
156
161
  }
157
162
  function GamepadDebug({ enabled = true, axisThreshold = 0.15, }) {
158
- const { bridge, pressedButtons, axes, buttonValues } = (0, useGamepad_1.default)({
163
+ var _a, _b, _c, _d;
164
+ const { bridge, pressedButtons, axes, buttonValues, info, vibrate, stopVibration, } = (0, useGamepad_1.default)({
159
165
  enabled,
160
166
  axisThreshold,
161
167
  });
@@ -163,7 +169,45 @@ function GamepadDebug({ enabled = true, axisThreshold = 0.15, }) {
163
169
  const pressed = (key) => pressedButtons.has(key);
164
170
  const axisValue = (key) => { var _a; return (_a = axes[key]) !== null && _a !== void 0 ? _a : 0; };
165
171
  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))) })] })] })] }) }));
172
+ const isConnected = info.connected;
173
+ const controllerLabel = (_a = info.id) !== null && _a !== void 0 ? _a : "No controller detected";
174
+ const vendorLabel = (_b = info.vendor) !== null && _b !== void 0 ? _b : "—";
175
+ const productLabel = (_c = info.product) !== null && _c !== void 0 ? _c : "—";
176
+ const mappingLabel = (_d = info.mapping) !== null && _d !== void 0 ? _d : "unknown";
177
+ const timestampLabel = info.timestamp != null ? Math.round(info.timestamp).toString() : "—";
178
+ const infoItems = [
179
+ { label: "Name", value: controllerLabel },
180
+ { label: "Vendor", value: vendorLabel },
181
+ { label: "Product", value: productLabel },
182
+ { label: "Mapping", value: mappingLabel },
183
+ { label: "Index", value: info.index != null ? `${info.index}` : "—" },
184
+ { label: "Axes", value: `${info.axes || 0}` },
185
+ { label: "Buttons", value: `${info.buttons || 0}` },
186
+ { label: "Vibration", value: info.canVibrate ? "Yes" : "No" },
187
+ { label: "Timestamp", value: timestampLabel },
188
+ ];
189
+ 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: [
190
+ styles.statusPill,
191
+ isConnected
192
+ ? styles.statusPillConnected
193
+ : styles.statusPillWaiting,
194
+ ], 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 })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.testsRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: [
195
+ styles.button,
196
+ !info.canVibrate && styles.buttonDisabled,
197
+ ], onPress: () => info.canVibrate && vibrate(900, 1), disabled: !info.canVibrate, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
198
+ styles.buttonText,
199
+ !info.canVibrate &&
200
+ styles.buttonTextDisabled,
201
+ ], children: "Vibration, 1 sec" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: [
202
+ styles.button,
203
+ !info.canVibrate && styles.buttonDisabled,
204
+ ], onPress: () => info.canVibrate && stopVibration(), disabled: !info.canVibrate, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
205
+ styles.buttonText,
206
+ !info.canVibrate &&
207
+ styles.buttonTextDisabled,
208
+ ], children: "Stop vibration" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperTextSmall, children: info.canVibrate
209
+ ? "Uses vibrationActuator when available."
210
+ : "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.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))) })] })] })] }) }));
167
211
  }
168
212
  const styles = react_native_1.StyleSheet.create({
169
213
  container: {
@@ -279,6 +323,65 @@ const styles = react_native_1.StyleSheet.create({
279
323
  fontWeight: "700",
280
324
  fontSize: 16,
281
325
  },
326
+ statusRow: {
327
+ flexDirection: "row",
328
+ alignItems: "center",
329
+ justifyContent: "space-between",
330
+ gap: 8,
331
+ },
332
+ statusPill: {
333
+ paddingHorizontal: 10,
334
+ paddingVertical: 4,
335
+ borderRadius: 999,
336
+ borderWidth: 1,
337
+ },
338
+ statusPillConnected: {
339
+ backgroundColor: "#dcfce7",
340
+ borderColor: "#16a34a",
341
+ },
342
+ statusPillWaiting: {
343
+ backgroundColor: "#fee2e2",
344
+ borderColor: "#f97316",
345
+ },
346
+ statusPillText: {
347
+ fontSize: 12,
348
+ fontWeight: "700",
349
+ color: "#0f172a",
350
+ },
351
+ helperText: {
352
+ color: "#475569",
353
+ fontSize: 12,
354
+ },
355
+ helperTextSmall: {
356
+ color: "#94a3b8",
357
+ fontSize: 11,
358
+ },
359
+ infoGrid: {
360
+ flexDirection: "row",
361
+ flexWrap: "wrap",
362
+ gap: 8,
363
+ },
364
+ infoItem: {
365
+ flexGrow: 1,
366
+ flexBasis: "45%",
367
+ minWidth: 140,
368
+ backgroundColor: "#f8fafc",
369
+ borderWidth: 1,
370
+ borderColor: "#e2e8f0",
371
+ borderRadius: 8,
372
+ padding: 8,
373
+ gap: 2,
374
+ },
375
+ infoLabel: {
376
+ color: "#475569",
377
+ fontSize: 11,
378
+ fontWeight: "700",
379
+ },
380
+ infoValue: {
381
+ color: "#0f172a",
382
+ fontSize: 13,
383
+ fontWeight: "600",
384
+ },
282
385
  controller: {
283
386
  backgroundColor: "#f8fafc",
284
387
  borderWidth: 1,
@@ -286,6 +389,50 @@ const styles = react_native_1.StyleSheet.create({
286
389
  padding: 12,
287
390
  alignItems: "center",
288
391
  justifyContent: "center",
392
+ position: "relative",
393
+ overflow: "hidden",
394
+ },
395
+ loaderOverlay: {
396
+ position: "absolute",
397
+ top: 0,
398
+ left: 0,
399
+ right: 0,
400
+ bottom: 0,
401
+ backgroundColor: "rgba(255,255,255,0.92)",
402
+ alignItems: "center",
403
+ justifyContent: "center",
404
+ padding: 12,
405
+ gap: 6,
406
+ zIndex: 50,
407
+ },
408
+ loaderText: {
409
+ color: "#0f172a",
410
+ fontSize: 12,
411
+ textAlign: "center",
412
+ },
413
+ testsRow: {
414
+ flexDirection: "row",
415
+ flexWrap: "wrap",
416
+ gap: 8,
417
+ },
418
+ button: {
419
+ backgroundColor: "#2563eb",
420
+ paddingVertical: 10,
421
+ paddingHorizontal: 12,
422
+ borderRadius: 8,
423
+ alignItems: "center",
424
+ justifyContent: "center",
425
+ },
426
+ buttonDisabled: {
427
+ backgroundColor: "#cbd5e1",
428
+ },
429
+ buttonText: {
430
+ color: "#f8fafc",
431
+ fontWeight: "700",
432
+ fontSize: 13,
433
+ },
434
+ buttonTextDisabled: {
435
+ color: "#475569",
289
436
  },
290
437
  axesGrid: {
291
438
  flexDirection: "row",
@@ -349,7 +496,7 @@ const styles = react_native_1.StyleSheet.create({
349
496
  width: 395,
350
497
  height: 160,
351
498
  backgroundColor: "#e0e0e0",
352
- marginLeft: 27,
499
+ marginLeft: 50, // 27
353
500
  borderRadius: 25,
354
501
  position: "absolute",
355
502
  zIndex: 0,
@@ -359,7 +506,7 @@ const styles = react_native_1.StyleSheet.create({
359
506
  width: 150,
360
507
  height: 80,
361
508
  backgroundColor: "#333",
362
- marginLeft: 147,
509
+ marginLeft: 170,
363
510
  borderRadius: 7,
364
511
  position: "absolute",
365
512
  zIndex: 10,
@@ -375,7 +522,7 @@ const styles = react_native_1.StyleSheet.create({
375
522
  height: 25,
376
523
  position: "absolute",
377
524
  backgroundColor: "#95a5a6",
378
- marginLeft: 125,
525
+ marginLeft: 148,
379
526
  marginTop: 85,
380
527
  borderRadius: 5,
381
528
  zIndex: 10,
@@ -390,7 +537,7 @@ const styles = react_native_1.StyleSheet.create({
390
537
  height: 25,
391
538
  position: "absolute",
392
539
  backgroundColor: "#95a5a6",
393
- marginLeft: 305,
540
+ marginLeft: 327,
394
541
  marginTop: 85,
395
542
  borderRadius: 5,
396
543
  zIndex: 10,
@@ -405,7 +552,7 @@ const styles = react_native_1.StyleSheet.create({
405
552
  height: 260,
406
553
  backgroundColor: "#e0e0e0",
407
554
  position: "absolute",
408
- left: 0,
555
+ left: 23,
409
556
  top: 95,
410
557
  transform: [{ rotate: "11deg" }],
411
558
  borderTopLeftRadius: 30,
@@ -426,7 +573,7 @@ const styles = react_native_1.StyleSheet.create({
426
573
  height: 260,
427
574
  backgroundColor: "#e0e0e0",
428
575
  position: "absolute",
429
- left: 330,
576
+ left: 353,
430
577
  top: 95,
431
578
  transform: [{ rotate: "-11deg" }],
432
579
  borderTopLeftRadius: 30,
@@ -448,7 +595,7 @@ const styles = react_native_1.StyleSheet.create({
448
595
  height: 112,
449
596
  borderRadius: 56,
450
597
  marginTop: 5,
451
- marginLeft: 10,
598
+ marginLeft: 0,
452
599
  borderWidth: 1,
453
600
  borderColor: "#b0b0b0",
454
601
  position: "relative",
@@ -514,7 +661,7 @@ const styles = react_native_1.StyleSheet.create({
514
661
  width: 112,
515
662
  height: 112,
516
663
  borderRadius: 56,
517
- marginTop: 2,
664
+ marginTop: 4,
518
665
  borderWidth: 1,
519
666
  borderColor: "#b0b0b0",
520
667
  position: "relative",
package/dist/types.d.ts CHANGED
@@ -1,26 +1,41 @@
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" | `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
+ };
22
37
  export type StatusEvent = {
23
- type: 'status';
24
- state: 'connected' | 'disconnected';
38
+ type: "status";
39
+ state: "connected" | "disconnected";
25
40
  };
26
- export type GamepadMessage = DpadEvent | ButtonEvent | AxisEvent | StatusEvent;
41
+ export type GamepadMessage = DpadEvent | ButtonEvent | AxisEvent | StatusEvent | InfoEvent;
@@ -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,20 @@ 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);
20
34
  (0, react_1.useEffect)(() => {
21
35
  pressedRef.current = pressedButtons;
22
36
  }, [pressedButtons]);
@@ -43,6 +57,34 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
43
57
  const handleDpad = (0, react_1.useCallback)((event) => {
44
58
  onDpad === null || onDpad === void 0 ? void 0 : onDpad(event);
45
59
  }, [onDpad]);
60
+ const handleInfo = (0, react_1.useCallback)((event) => {
61
+ setInfo(event);
62
+ onInfo === null || onInfo === void 0 ? void 0 : onInfo(event);
63
+ }, [onInfo]);
64
+ const handleStatus = (0, react_1.useCallback)((event) => {
65
+ if (event.state === "disconnected") {
66
+ setInfo((prev) => ({
67
+ ...prev,
68
+ connected: false,
69
+ }));
70
+ }
71
+ onStatus === null || onStatus === void 0 ? void 0 : onStatus(event);
72
+ }, [onStatus]);
73
+ const vibrate = (0, react_1.useCallback)((duration = 800, strength = 1) => {
74
+ vibrationNonce.current += 1;
75
+ const safeStrength = Math.max(0, Math.min(strength, 1));
76
+ setVibrationRequest({
77
+ type: "once",
78
+ duration,
79
+ strong: safeStrength,
80
+ weak: safeStrength,
81
+ nonce: vibrationNonce.current,
82
+ });
83
+ }, []);
84
+ const stopVibration = (0, react_1.useCallback)(() => {
85
+ vibrationNonce.current += 1;
86
+ setVibrationRequest({ type: "stop", nonce: vibrationNonce.current });
87
+ }, []);
46
88
  (0, react_1.useEffect)(() => {
47
89
  if (!enabled && pressedRef.current.size) {
48
90
  pressedRef.current = new Set();
@@ -51,9 +93,31 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
51
93
  if (!enabled) {
52
94
  setAxes({ leftX: 0, leftY: 0, rightX: 0, rightY: 0 });
53
95
  setButtonValues({});
96
+ setInfo((prev) => ({
97
+ ...prev,
98
+ connected: false,
99
+ }));
54
100
  }
55
101
  }, [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]);
102
+ 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, vibrationRequest: vibrationRequest !== null && vibrationRequest !== void 0 ? vibrationRequest : undefined })), [
103
+ enabled,
104
+ axisThreshold,
105
+ handleAxis,
106
+ handleButton,
107
+ handleDpad,
108
+ handleInfo,
109
+ handleStatus,
110
+ vibrationRequest,
111
+ ]);
57
112
  const isPressed = (0, react_1.useCallback)((key) => pressedRef.current.has(key), []);
58
- return { pressedButtons, axes, buttonValues, isPressed, bridge };
113
+ return {
114
+ pressedButtons,
115
+ axes,
116
+ buttonValues,
117
+ isPressed,
118
+ bridge,
119
+ info,
120
+ vibrate,
121
+ stopVibration,
122
+ };
59
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-earl-gamepad",
3
- "version": "0.5.1",
3
+ "version": "0.6.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",