@pickleball/expo-sdk 0.2.0 → 1.0.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +155 -101
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +14 -2
  6. package/dist/advanced.d.cts +2 -2
  7. package/dist/advanced.d.ts +2 -2
  8. package/dist/advanced.js +17 -5
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-62YV3PCD.cjs +139 -0
  12. package/dist/chunk-6F3HN6US.js +50 -0
  13. package/dist/chunk-6YE5BL3A.cjs +50 -0
  14. package/dist/chunk-7Z7TN5N3.js +318 -0
  15. package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
  16. package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
  17. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  18. package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
  19. package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
  20. package/dist/chunk-ZSKSXOCZ.js +139 -0
  21. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  22. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  23. package/dist/device-agent/index.cjs +973 -44
  24. package/dist/device-agent/index.d.cts +339 -3
  25. package/dist/device-agent/index.d.ts +339 -3
  26. package/dist/device-agent/index.js +940 -11
  27. package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
  28. package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
  29. package/dist/index.cjs +419 -937
  30. package/dist/index.d.cts +315 -32
  31. package/dist/index.d.ts +315 -32
  32. package/dist/index.js +423 -941
  33. package/dist/plugin.cjs +11 -13
  34. package/dist/plugin.d.cts +15 -1
  35. package/dist/plugin.d.ts +15 -1
  36. package/dist/plugin.js +7 -9
  37. package/dist/realtime/index.cjs +31 -0
  38. package/dist/realtime/index.d.cts +15 -0
  39. package/dist/realtime/index.d.ts +15 -0
  40. package/dist/realtime/index.js +31 -0
  41. package/dist/ui/index.cjs +1297 -0
  42. package/dist/ui/index.d.cts +493 -0
  43. package/dist/ui/index.d.ts +493 -0
  44. package/dist/ui/index.js +1297 -0
  45. package/dist/version.cjs +2 -2
  46. package/dist/version.js +1 -1
  47. package/package.json +31 -15
  48. package/src/advanced.ts +11 -9
  49. package/src/auto-resume.tsx +259 -0
  50. package/src/camera-controls.ts +99 -0
  51. package/src/camera-grant-provider.ts +392 -0
  52. package/src/camera-settings.ts +133 -0
  53. package/src/contracts.ts +73 -0
  54. package/src/device-agent/agent.ts +262 -9
  55. package/src/device-agent/beat-policy.ts +46 -0
  56. package/src/device-agent/component.tsx +11 -0
  57. package/src/device-agent/convex-beat-transport.ts +140 -0
  58. package/src/device-agent/convex-command-subscription.ts +101 -0
  59. package/src/device-agent/convex-control-channel.ts +7 -1
  60. package/src/device-agent/http-transport.ts +22 -1
  61. package/src/device-agent/index.ts +22 -0
  62. package/src/device-agent/mqtt-channel.ts +379 -0
  63. package/src/device-agent/mqtt-codec.ts +347 -0
  64. package/src/device-agent/tournament.ts +352 -0
  65. package/src/engine.ts +580 -15
  66. package/src/http-session-provider.ts +21 -189
  67. package/src/index.ts +84 -5
  68. package/src/local-video-quality.ts +43 -0
  69. package/src/native-runtime.ts +234 -78
  70. package/src/plugin.ts +19 -6
  71. package/src/provider.tsx +94 -18
  72. package/src/realtime/index.ts +56 -0
  73. package/src/resume-policy.ts +85 -0
  74. package/src/resume.ts +132 -0
  75. package/src/rtmp-room-adapter.ts +164 -12
  76. package/src/session-grant.ts +256 -0
  77. package/src/ui/camera-screen.tsx +484 -0
  78. package/src/ui/controls-sheet.tsx +445 -0
  79. package/src/ui/copy.ts +326 -0
  80. package/src/ui/helpers.ts +105 -0
  81. package/src/ui/hooks.ts +59 -0
  82. package/src/ui/index.ts +42 -0
  83. package/src/ui/preview-view.tsx +39 -0
  84. package/src/ui/primitives.tsx +304 -0
  85. package/src/uplink-check.ts +173 -0
  86. package/dist/advanced-BXDrDl1h.d.ts +0 -316
  87. package/dist/advanced-BdAa2Erv.d.cts +0 -316
  88. package/src/livekit-room-adapter.ts +0 -390
  89. package/src/livestream-screen.tsx +0 -1002
package/dist/version.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
2
 
3
- var _chunkTRLASNPQcjs = require('./chunk-TRLASNPQ.cjs');
3
+ var _chunkGXQYMPHEcjs = require('./chunk-GXQYMPHE.cjs');
4
4
  require('./chunk-7OX2DSKI.cjs');
5
5
 
6
6
 
7
- exports.PICKLEBALL_EXPO_SDK_VERSION = _chunkTRLASNPQcjs.PICKLEBALL_EXPO_SDK_VERSION;
7
+ exports.PICKLEBALL_EXPO_SDK_VERSION = _chunkGXQYMPHEcjs.PICKLEBALL_EXPO_SDK_VERSION;
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  PICKLEBALL_EXPO_SDK_VERSION
3
- } from "./chunk-6JOMESNT.js";
3
+ } from "./chunk-XL4CISVQ.js";
4
4
  import "./chunk-KFQGP6VL.js";
5
5
  export {
6
6
  PICKLEBALL_EXPO_SDK_VERSION
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pickleball/expo-sdk",
3
- "version": "0.2.0",
4
- "description": "Foreground livestream engine, React hooks, UI, and Expo config plugin for Pickleball Live",
3
+ "version": "1.0.0",
4
+ "description": "RTMP livestream engine, camera-grant session provider, React hooks, ready-made camera UI, and Expo config plugin for Pickleball Live",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -93,6 +93,28 @@
93
93
  }
94
94
  },
95
95
  "./app.plugin.js": "./app.plugin.js",
96
+ "./realtime": {
97
+ "react-native": "./src/realtime/index.ts",
98
+ "import": {
99
+ "types": "./dist/realtime/index.d.ts",
100
+ "default": "./dist/realtime/index.js"
101
+ },
102
+ "require": {
103
+ "types": "./dist/realtime/index.d.cts",
104
+ "default": "./dist/realtime/index.cjs"
105
+ }
106
+ },
107
+ "./ui": {
108
+ "react-native": "./src/ui/index.ts",
109
+ "import": {
110
+ "types": "./dist/ui/index.d.ts",
111
+ "default": "./dist/ui/index.js"
112
+ },
113
+ "require": {
114
+ "types": "./dist/ui/index.d.cts",
115
+ "default": "./dist/ui/index.cjs"
116
+ }
117
+ },
96
118
  "./version": {
97
119
  "react-native": "./src/version.ts",
98
120
  "import": {
@@ -119,8 +141,9 @@
119
141
  "keywords": [
120
142
  "expo",
121
143
  "react-native",
122
- "livekit",
144
+ "rtmp",
123
145
  "livestream",
146
+ "pickleball",
124
147
  "video"
125
148
  ],
126
149
  "sideEffects": [
@@ -128,55 +151,48 @@
128
151
  "./src/native-runtime.ts"
129
152
  ],
130
153
  "peerDependencies": {
131
- "@livekit/react-native": ">=2.11.1 <3",
132
- "@livekit/react-native-webrtc": ">=144.1.1 <145",
133
- "@pickleball/rtmp-native": ">=0.1.0 <1",
154
+ "@pickleball/rtmp-native": ">=0.2.0 <1",
134
155
  "@react-native-community/netinfo": ">=11 <13",
135
156
  "expo": ">=56 <58",
136
157
  "expo-audio": ">=56 <58",
137
158
  "expo-file-system": ">=56 <58",
138
159
  "expo-keep-awake": ">=55 <58",
139
160
  "expo-sqlite": ">=55 <58",
140
- "livekit-client": ">=2.20.1 <3",
141
161
  "react": ">=19 <20",
142
162
  "react-native": ">=0.85 <0.87",
143
163
  "react-native-safe-area-context": ">=5 <6"
144
164
  },
145
165
  "dependencies": {
146
- "@livekit/react-native-expo-plugin": "1.0.2",
147
166
  "convex": "^1.42.1",
148
167
  "@pickleball/remote-native": "0.1.0",
149
168
  "@pickleball/voice-native": "0.1.0"
150
169
  },
151
170
  "devDependencies": {
152
171
  "@expo/config-plugins": "~57.0.0",
153
- "@livekit/react-native": "2.11.1",
154
- "@livekit/react-native-webrtc": "144.1.1",
155
172
  "@react-native-community/netinfo": "12.0.1",
156
173
  "@testing-library/react-native": "^13.3.3",
157
174
  "@types/jest": "^29.5.14",
158
175
  "@types/node": "^22.15.0",
159
176
  "@types/react": "~19.2.2",
160
- "expo": "57.0.4",
177
+ "expo": "~57.0.14",
161
178
  "expo-audio": "57.0.2",
162
179
  "expo-file-system": "57.0.0",
163
180
  "expo-keep-awake": "57.0.0",
164
181
  "expo-sqlite": "57.0.0",
165
182
  "jest": "^29.7.0",
166
183
  "jest-expo": "~57.0.0",
167
- "livekit-client": "2.20.1",
168
184
  "react": "19.2.3",
169
185
  "react-dom": "19.2.3",
170
- "react-native": "0.86.0",
186
+ "react-native": "0.86.2",
171
187
  "react-native-safe-area-context": "~5.7.0",
172
188
  "react-test-renderer": "19.2.3",
173
189
  "tsup": "^8.5.0",
174
190
  "typescript": "^5.8.0",
175
- "@pickleball/rtmp-native": "0.1.0",
191
+ "@pickleball/rtmp-native": "0.2.0",
176
192
  "@pickleball/shared": "0.1.0"
177
193
  },
178
194
  "peerDependenciesMeta": {
179
- "@pickleball/rtmp-native": {
195
+ "expo-audio": {
180
196
  "optional": true
181
197
  }
182
198
  },
package/src/advanced.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * API nâng cao: engine internals, dependency injection, adapter LiveKit và các
2
+ * API nâng cao: engine internals, dependency injection, adapter RTMP và các
3
3
  * factory runtime mặc định — dùng cho mock/test hoặc tuỳ biến sâu. App tích hợp
4
4
  * thông thường không cần import từ đây.
5
5
  */
@@ -14,31 +14,33 @@ export {
14
14
  type SdkRuntime,
15
15
  type SdkStorage,
16
16
  type SdkNetwork,
17
+ type SessionCommandChannel,
17
18
  type TelemetrySink,
18
19
  type LivestreamSnapshot,
19
20
  type DeviceMetricsSample,
20
21
  type CaptureOrientation,
21
22
  type CameraDeviceInfo,
22
23
  } from "./engine";
23
- export {
24
- LiveKitRoomAdapter,
25
- type NativeLiveKitBindings,
26
- type LocalPreviewReference,
27
- } from "./livekit-room-adapter";
28
24
  export {
29
25
  RtmpRoomAdapter,
30
26
  type NativeRtmpBindings,
31
27
  type RtmpStatus,
32
28
  } from "./rtmp-room-adapter";
33
29
  export {
34
- createNativeLiveKitBindings,
30
+ isBootstrap,
31
+ isCameraGrant,
32
+ isSessionGrant,
33
+ isSessionRefresh,
34
+ isTelemetryCredentials,
35
+ sessionApiBaseFromTelemetryEndpoint,
36
+ } from "./session-grant";
37
+ export {
35
38
  createDefaultRuntime,
39
+ createRtmpRuntime,
36
40
  createDefaultStorage,
37
41
  createDefaultNetwork,
38
42
  createDefaultTelemetry,
39
43
  createDefaultRoomFactory,
40
- // Mode device-rtmp: thiết bị đẩy RTMP thẳng lên server tự host, không dùng
41
- // LiveKit. Truyền vào prop roomFactory của PickleballLiveProvider để đổi mode.
42
44
  createRtmpRoomFactory,
43
45
  createNativeRtmpBindings,
44
46
  createDefaultDeviceMetrics,
@@ -0,0 +1,259 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { StartLivestreamInput } from "./contracts";
3
+ import { useLivestream, useLivestreamHost } from "./provider";
4
+ import type { ResumableSessionProvider, ResumeAttempt } from "./resume";
5
+ import { resumeBlocksArm, resumeExternalSessionId, resumeRetryDelayMs } from "./resume-policy";
6
+
7
+ /**
8
+ * Ba mảnh "tự động" chuyển từ apps/device-streamer vào SDK — mọi app RTMP cần
9
+ * chúng, không riêng máy fleet:
10
+ *
11
+ * - PickleballAutoPrepare: camera luôn bật (prepare khi idle/ended).
12
+ * - PickleballAutoResume: app mở lại thấy phiên còn sống → nhận lại đúng phiên.
13
+ * - useStandbySession: chế độ sân — arm một phiên chờ, server lật live bằng lệnh.
14
+ *
15
+ * Các bẫy đã đo trên fleet (18–19/08/2026) nằm nguyên trong đây: retry publish
16
+ * khi encoder chưa có SPS, thoát hố "error" khi còn phiên chờ nhận lại, và
17
+ * KHÔNG arm phiên mới khi còn phiên chờ nhận lại.
18
+ *
19
+ * Kỷ luật dependency: mọi effect ở đây chỉ phụ thuộc vào TRẠNG THÁI (state,
20
+ * sessionId, id phiên chờ, cờ enabled) — hành động của engine, props cấu hình
21
+ * (resumeInput, retryDelayMs, input) và callback đi qua ref. App đối tác thường
22
+ * truyền object/hàm inline mỗi lần render; đưa chúng vào deps là mỗi render
23
+ * huỷ lượt resume đang bay, bắn lượt mới ngay (spam refresh) và timer thử lại
24
+ * không bao giờ kịp nổ.
25
+ */
26
+
27
+ export type ResumeAttemptOutcome =
28
+ | ResumeAttempt["kind"]
29
+ | "start_failed"
30
+ | "publish_retry"
31
+ | "reprepare";
32
+
33
+ export interface PickleballAutoResumeProps {
34
+ provider: ResumableSessionProvider;
35
+ /** Thông tin dựng lượt start nhận lại (title mặc định khi không biết tên phiên). */
36
+ resumeInput: {
37
+ consentVersion: string;
38
+ visibility?: "public" | "private";
39
+ metadata?: Record<string, string>;
40
+ titleFor?: (sessionId: string) => string;
41
+ };
42
+ /** false = tạm dừng (VD máy đang nghỉ). */
43
+ enabled?: boolean;
44
+ /**
45
+ * Nguồn thứ hai của id phiên còn sống (fleet: serverSession từ heartbeat).
46
+ * adoptableSessionId (dấu trên đĩa) luôn được ưu tiên vì có sớm hơn vài giây.
47
+ * Truyền CÙNG giá trị này vào `useStandbySession({resumeSessionId})` nếu app
48
+ * arm phiên chờ, để arm nhường đường nhận lại.
49
+ */
50
+ sessionIdHint?: string | null;
51
+ retryDelayMs?: (attempt: number) => number;
52
+ onAttempt?: (sessionId: string, outcome: ResumeAttemptOutcome) => void;
53
+ }
54
+
55
+ function useLatest<T>(value: T) {
56
+ const ref = useRef(value);
57
+ ref.current = value;
58
+ return ref;
59
+ }
60
+
61
+ export function PickleballAutoResume(props: PickleballAutoResumeProps): null {
62
+ const {
63
+ provider,
64
+ resumeInput,
65
+ enabled = true,
66
+ sessionIdHint = null,
67
+ retryDelayMs = resumeRetryDelayMs,
68
+ onAttempt,
69
+ } = props;
70
+ const live = useLivestream();
71
+ const { engine } = useLivestreamHost();
72
+ const goneRef = useRef<string | null>(null);
73
+ const attemptRef = useRef(0);
74
+ const [retryTick, setRetryTick] = useState(0);
75
+ const { state, sessionId, adoptableSessionId } = live;
76
+ const resumeId = adoptableSessionId ?? sessionIdHint;
77
+ const liveRef = useLatest(live);
78
+ const onAttemptRef = useLatest(onAttempt);
79
+ const resumeInputRef = useLatest(resumeInput);
80
+ const retryDelayRef = useLatest(retryDelayMs);
81
+
82
+ useEffect(() => {
83
+ if (!enabled) return;
84
+ if (!resumeId || sessionId !== null || state !== "preview") return;
85
+ if (goneRef.current === resumeId) return;
86
+
87
+ let cancelled = false;
88
+ let timer: ReturnType<typeof setTimeout> | undefined;
89
+ const scheduleRetry = () => {
90
+ const delay = retryDelayRef.current(attemptRef.current);
91
+ attemptRef.current += 1;
92
+ timer = setTimeout(() => {
93
+ if (!cancelled) setRetryTick((tick) => tick + 1);
94
+ }, delay);
95
+ };
96
+
97
+ void (async () => {
98
+ const result = await provider.resume(resumeId);
99
+ if (cancelled) return;
100
+ onAttemptRef.current?.(resumeId, result.kind);
101
+ if (result.kind === "gone") {
102
+ // Server nói phiên không còn. PHẢI bỏ dấu, không chỉ ghi nhớ ở ref: dấu
103
+ // còn nằm đó là mọi lượt start phiên MỚI bị chặn, mà lượt duy nhất được
104
+ // cho qua lại là nhận lại chính phiên đã chết — máy kẹt vĩnh viễn ở "error".
105
+ goneRef.current = resumeId;
106
+ attemptRef.current = 0;
107
+ void engine.abandonAdoptableSession(resumeId).catch(() => undefined);
108
+ return;
109
+ }
110
+ if (result.kind === "retry") {
111
+ scheduleRetry();
112
+ return;
113
+ }
114
+ attemptRef.current = 0;
115
+ provider.adoptResumeGrant(result.grant);
116
+ const resumeInput = resumeInputRef.current;
117
+ await liveRef.current
118
+ .start(
119
+ {
120
+ externalSessionId: resumeExternalSessionId(resumeId),
121
+ title: resumeInput.titleFor?.(resumeId) ?? "Phiên đang khôi phục",
122
+ consentVersion: resumeInput.consentVersion,
123
+ visibility: resumeInput.visibility ?? "public",
124
+ ...(resumeInput.metadata ? { metadata: resumeInput.metadata } : {}),
125
+ },
126
+ // Trạng thái lấy từ chính phản hồi refresh, KHÔNG đoán: gán cứng "live" là
127
+ // camera tự lên sóng khi chủ nó chỉ đang trực sân (18/08/2026).
128
+ { autoPublish: result.live },
129
+ )
130
+ .catch(() => {
131
+ // start hỏng (camera chưa sẵn, mạng rớt giữa chừng) cũng phải thử lại —
132
+ // bỏ qua là để phiên sống mồ côi tới khi lưới server dọn.
133
+ onAttemptRef.current?.(resumeId, "start_failed");
134
+ if (!cancelled) scheduleRetry();
135
+ });
136
+ })();
137
+
138
+ return () => {
139
+ cancelled = true;
140
+ if (timer) clearTimeout(timer);
141
+ };
142
+ }, [enabled, provider, resumeId, sessionId, state, retryTick, engine, liveRef, onAttemptRef, resumeInputRef, retryDelayRef]);
143
+
144
+ // Nhận lại hỏng ở bước PUBLISH chứ không phải bước xin grant: native ném vì
145
+ // encoder chưa phát SPS (app vừa mở, camera vừa bật). Engine ĐÃ SỞ HỮU phiên
146
+ // nên gọi start() lần nữa là sai — đường đúng là retry(), dùng lại grant sẵn có.
147
+ useEffect(() => {
148
+ if (!enabled || state !== "error" || sessionId === null) return;
149
+ const timer = setTimeout(() => {
150
+ onAttemptRef.current?.(sessionId, "publish_retry");
151
+ void liveRef.current.retry().catch(() => undefined);
152
+ }, retryDelayRef.current(attemptRef.current++));
153
+ return () => clearTimeout(timer);
154
+ }, [enabled, state, sessionId, liveRef, onAttemptRef, retryDelayRef]);
155
+
156
+ // Thoát hố đen "error" khi CHƯA có session mà còn phiên chờ nhận lại: transition
157
+ // error → preparing hợp lệ, prepare lại để về preview cho vòng nhận lại chạy tiếp.
158
+ useEffect(() => {
159
+ if (!enabled || state !== "error" || sessionId !== null || !adoptableSessionId) return;
160
+ const timer = setTimeout(() => {
161
+ onAttemptRef.current?.(adoptableSessionId, "reprepare");
162
+ void liveRef.current.prepare().catch(() => undefined);
163
+ }, 2_000);
164
+ return () => clearTimeout(timer);
165
+ }, [enabled, state, sessionId, adoptableSessionId, liveRef, onAttemptRef]);
166
+
167
+ useEffect(() => {
168
+ if (state === "live" || state === "standby") attemptRef.current = 0;
169
+ }, [state]);
170
+
171
+ return null;
172
+ }
173
+
174
+ export interface PickleballAutoPrepareProps {
175
+ enabled?: boolean;
176
+ /** Khung cố định (fleet: landscape). Bỏ trống = theo hướng máy như engine mặc định. */
177
+ captureOrientation?: "portrait" | "landscape";
178
+ }
179
+
180
+ /**
181
+ * Camera luôn sẵn: prepare khi idle và SAU KHI phiên kết thúc (thiếu nhánh
182
+ * `ended` là camera tối sau phiên đầu tiên). Không chạy khi đang lỗi — người
183
+ * dùng cần thấy lỗi, và AutoResume lo nhánh lỗi có phiên chờ.
184
+ */
185
+ export function PickleballAutoPrepare(props: PickleballAutoPrepareProps): null {
186
+ const { enabled = true, captureOrientation } = props;
187
+ const live = useLivestream();
188
+ const { state } = live;
189
+ const liveRef = useLatest(live);
190
+ const orientedRef = useRef(false);
191
+
192
+ useEffect(() => {
193
+ if (!enabled || !captureOrientation || orientedRef.current) return;
194
+ orientedRef.current = true;
195
+ void liveRef.current.setCaptureOrientation(captureOrientation).catch(() => undefined);
196
+ }, [enabled, captureOrientation, liveRef]);
197
+
198
+ useEffect(() => {
199
+ if (!enabled) return;
200
+ if (state !== "idle" && state !== "ended") return;
201
+ void liveRef.current.prepare().catch(() => undefined);
202
+ }, [enabled, state, liveRef]);
203
+
204
+ return null;
205
+ }
206
+
207
+ export interface UseStandbySessionOptions {
208
+ /** Lượt start chờ; đổi `externalSessionId` (trận kế) mới arm lại sau khi phiên trước kết thúc. */
209
+ input: StartLivestreamInput;
210
+ enabled?: boolean;
211
+ /**
212
+ * Id phiên còn sống theo nguồn NGOÀI đĩa (fleet: heartbeat; app đối tác: id
213
+ * backend trả về) — cùng giá trị truyền vào `PickleballAutoResume.sessionIdHint`.
214
+ * Có nó là arm NHƯỜNG: không tạo phiên chờ thứ hai trong lúc AutoResume đang
215
+ * nối lại phiên cũ.
216
+ */
217
+ resumeSessionId?: string | null;
218
+ }
219
+
220
+ /**
221
+ * Chế độ SÂN: điện thoại đứng chờ (standby) với grant đã cấp; trọng tài bấm
222
+ * match-start là server gửi lệnh `start` qua kênh lệnh và engine tự phát.
223
+ * Arm đúng một lần cho mỗi externalSessionId, và NHƯỜNG khi còn phiên chờ nhận
224
+ * lại (resumeBlocksArm) để không tạo phiên chờ thứ hai đè phiên cũ.
225
+ */
226
+ export function useStandbySession(options: UseStandbySessionOptions): { armed: boolean } {
227
+ const { input, enabled = true, resumeSessionId = null } = options;
228
+ const live = useLivestream();
229
+ const { state, sessionId, adoptableSessionId } = live;
230
+ const liveRef = useLatest(live);
231
+ const armedForRef = useRef<string | null>(null);
232
+ // Chỉ externalSessionId là "khoá" của lượt arm; phần còn lại của input đọc
233
+ // qua ref để object inline mỗi lần render không kéo effect chạy lại.
234
+ const inputRef = useLatest(input);
235
+ const externalSessionId = input.externalSessionId;
236
+ const armed = sessionId !== null && armedForRef.current === externalSessionId;
237
+
238
+ useEffect(() => {
239
+ if (!enabled) return;
240
+ if (state !== "preview" || sessionId !== null) return;
241
+ if (armedForRef.current === externalSessionId) return;
242
+ if (
243
+ resumeBlocksArm({
244
+ adoptableSessionId,
245
+ serverSessionId: resumeSessionId,
246
+ engineSessionId: sessionId,
247
+ })
248
+ ) {
249
+ return;
250
+ }
251
+ armedForRef.current = externalSessionId;
252
+ void liveRef.current.start(inputRef.current, { autoPublish: false }).catch(() => {
253
+ // Cho phép thử lại ở lượt render sau (VD grant lỗi tạm thời).
254
+ armedForRef.current = null;
255
+ });
256
+ }, [enabled, state, sessionId, adoptableSessionId, resumeSessionId, externalSessionId, liveRef, inputRef]);
257
+
258
+ return { armed };
259
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Điều khiển camera RUNTIME (zoom, EV, khoá nét/sáng, đèn pin, chống chớp) —
3
+ * gọi THẲNG native module @pickleball/rtmp-native (singleton), chủ ý bypass
4
+ * engine/adapter vì đây là tinh chỉnh camera, không phải lifecycle phiên.
5
+ *
6
+ * Mọi hàm `typeof`-guard: binary cũ (rtmp-native 0.1.0 trên fleet 1.0.2) thiếu
7
+ * vài hàm thì trả null/no-op thay vì ném — UI ẩn control tương ứng.
8
+ */
9
+
10
+ declare const require: (name: string) => any;
11
+
12
+ export interface CameraCapabilityRange {
13
+ min: number;
14
+ max: number;
15
+ supported: boolean;
16
+ }
17
+
18
+ export interface CameraCapabilities {
19
+ zoom: CameraCapabilityRange;
20
+ exposureBias: CameraCapabilityRange & { step?: number };
21
+ torch: { supported: boolean };
22
+ focusLock: { supported: boolean };
23
+ exposureLock: { supported: boolean };
24
+ supportedFrameRates?: number[];
25
+ }
26
+
27
+ type NativeControls = {
28
+ getCameraCapabilities?: () => Promise<CameraCapabilities>;
29
+ setZoom?: (factor: number) => Promise<void>;
30
+ setExposureBias?: (ev: number) => Promise<void>;
31
+ setFocusLocked?: (locked: boolean) => Promise<void>;
32
+ setExposureLocked?: (locked: boolean) => Promise<void>;
33
+ setTorch?: (enabled: boolean) => Promise<void>;
34
+ setAntiFlicker?: (mode: "auto" | "50hz" | "60hz") => Promise<void>;
35
+ requestPermissions?: () => Promise<boolean>;
36
+ };
37
+
38
+ function nativeModule(): NativeControls | null {
39
+ try {
40
+ const loaded = require("@pickleball/rtmp-native");
41
+ const candidate = (loaded?.default ?? loaded) as NativeControls | undefined;
42
+ return candidate && typeof candidate === "object" ? candidate : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ async function callOptional<T>(
49
+ name: keyof NativeControls,
50
+ invoke: (native: NativeControls) => Promise<T> | undefined,
51
+ fallback: T,
52
+ ): Promise<T> {
53
+ const native = nativeModule();
54
+ if (!native || typeof native[name] !== "function") return fallback;
55
+ try {
56
+ const result = await invoke(native);
57
+ return result === undefined ? fallback : result;
58
+ } catch {
59
+ return fallback;
60
+ }
61
+ }
62
+
63
+ /** Bounds + cờ hỗ trợ của lens đang gắn; null khi module/hàm chưa có. */
64
+ export function getCameraCapabilities(): Promise<CameraCapabilities | null> {
65
+ return callOptional("getCameraCapabilities", (n) => n.getCameraCapabilities?.(), null);
66
+ }
67
+
68
+ export function setZoom(factor: number): Promise<void> {
69
+ return callOptional("setZoom", (n) => n.setZoom?.(factor), undefined);
70
+ }
71
+
72
+ export function setExposureBias(ev: number): Promise<void> {
73
+ return callOptional("setExposureBias", (n) => n.setExposureBias?.(ev), undefined);
74
+ }
75
+
76
+ export function setFocusLocked(locked: boolean): Promise<void> {
77
+ return callOptional("setFocusLocked", (n) => n.setFocusLocked?.(locked), undefined);
78
+ }
79
+
80
+ export function setExposureLocked(locked: boolean): Promise<void> {
81
+ return callOptional("setExposureLocked", (n) => n.setExposureLocked?.(locked), undefined);
82
+ }
83
+
84
+ export function setTorch(enabled: boolean): Promise<void> {
85
+ return callOptional("setTorch", (n) => n.setTorch?.(enabled), undefined);
86
+ }
87
+
88
+ /** Android-only: iOS/binary cũ thiếu hàm → no-op. Native tự áp lại mỗi lần mở camera/đổi lens. */
89
+ export function applyAntiFlicker(mode: "auto" | "50hz" | "60hz"): Promise<void> {
90
+ return callOptional("setAntiFlicker", (n) => n.setAntiFlicker?.(mode), undefined);
91
+ }
92
+
93
+ /**
94
+ * Xin quyền camera/mic qua native (iOS, rtmp-native ≥ 0.2). null = native chưa
95
+ * có hàm → caller tự quyết (AVFoundation sẽ hỏi lúc mở camera).
96
+ */
97
+ export function requestNativePermissions(): Promise<boolean | null> {
98
+ return callOptional("requestPermissions", (n) => n.requestPermissions?.(), null);
99
+ }