@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
@@ -1,94 +1,86 @@
1
- import type { EngineDependencies, SdkNetwork, SdkRuntime, SdkStorage, TelemetrySink } from "./engine";
2
- import { LiveKitRoomAdapter, type NativeLiveKitBindings } from "./livekit-room-adapter";
1
+ import type {
2
+ EngineDependencies,
3
+ SdkNetwork,
4
+ SdkRuntime,
5
+ SdkStorage,
6
+ SessionCommandChannel,
7
+ TelemetrySink,
8
+ } from "./engine";
9
+ import type {
10
+ SessionCommandPollResult,
11
+ SessionCommandRecord,
12
+ } from "./contracts";
3
13
  import { RtmpRoomAdapter, type NativeRtmpBindings } from "./rtmp-room-adapter";
4
14
  import { UnsupportedRuntimeError } from "./errors";
5
15
 
6
16
  declare const require: (name: string) => any;
7
17
 
8
- let globalsRegistered = false;
9
-
10
- export function createNativeLiveKitBindings(): NativeLiveKitBindings {
11
- const reactNativeLiveKit = require("@livekit/react-native");
12
- const liveKit = require("livekit-client");
13
- return {
14
- registerGlobals() {
15
- if (!globalsRegistered) {
16
- reactNativeLiveKit.registerGlobals();
17
- globalsRegistered = true;
18
- }
19
- },
20
- createRoom: () => new liveKit.Room({ adaptiveStream: false, dynacast: false }),
21
- createLocalTracks: liveKit.createLocalTracks,
22
- startAudioSession: () => reactNativeLiveKit.AudioSession.startAudioSession(),
23
- stopAudioSession: () => reactNativeLiveKit.AudioSession.stopAudioSession(),
24
- isLandscape() {
25
- const { width, height } = require("react-native").Dimensions.get("window");
26
- return width > height;
27
- },
28
- async listCameras() {
29
- const { mediaDevices } = require("@livekit/react-native-webrtc");
30
- const devices: Array<{
31
- kind?: string;
32
- deviceId?: string;
33
- label?: string;
34
- facing?: string;
35
- }> = await mediaDevices.enumerateDevices();
36
- return devices
37
- .filter((device) => device.kind === "videoinput" && device.deviceId)
38
- .map((device) => ({
39
- deviceId: String(device.deviceId),
40
- label: String(device.label ?? ""),
41
- facing:
42
- device.facing === "front" || device.facing === "environment"
43
- ? device.facing
44
- : ("unknown" as const),
45
- }));
46
- },
47
- cameraSource: liveKit.Track.Source.Camera,
48
- events: {
49
- reconnecting: liveKit.RoomEvent.Reconnecting,
50
- reconnected: liveKit.RoomEvent.Reconnected,
51
- disconnected: liveKit.RoomEvent.Disconnected,
52
- mediaDevicesError: liveKit.RoomEvent.MediaDevicesError,
53
- dataReceived: liveKit.RoomEvent.DataReceived,
54
- },
55
- };
56
- }
57
-
58
- export function createDefaultRuntime(): SdkRuntime {
59
- const { NativeModules, Platform } = require("react-native");
18
+ /**
19
+ * Runtime cho app RTMP-only (KHÔNG có LiveKit/WebRTC): assert bằng module
20
+ * @pickleball/rtmp-native, xin quyền bằng API quyền thuần (Android) hoặc hàm
21
+ * native `requestPermissions` (iOS, rtmp-native ≥ 0.2; thiếu thì AVFoundation
22
+ * hỏi lúc mở camera). Chuyển từ apps/device-streamer (createRtmpOnlyRuntime).
23
+ * Không dùng getUserMedia: máy MediaTek chỉ cho một camera mở cùng lúc.
24
+ */
25
+ export function createRtmpRuntime(): SdkRuntime {
26
+ const { Platform, PermissionsAndroid } = require("react-native");
60
27
  return {
61
28
  assertSupported() {
62
- if (
63
- (Platform.OS !== "ios" && Platform.OS !== "android") ||
64
- !NativeModules.LivekitReactNativeModule
65
- ) {
29
+ if (Platform.OS !== "ios" && Platform.OS !== "android") {
66
30
  throw new UnsupportedRuntimeError();
67
31
  }
32
+ try {
33
+ require("@pickleball/rtmp-native");
34
+ } catch {
35
+ throw new UnsupportedRuntimeError(
36
+ "@pickleball/rtmp-native is required: install it and rebuild the Development Build",
37
+ );
38
+ }
68
39
  },
69
40
  async requestPermissions() {
41
+ if (Platform.OS === "android") {
42
+ try {
43
+ const granted = await PermissionsAndroid.requestMultiple([
44
+ PermissionsAndroid.PERMISSIONS.CAMERA,
45
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
46
+ ]);
47
+ return (
48
+ granted[PermissionsAndroid.PERMISSIONS.CAMERA] ===
49
+ PermissionsAndroid.RESULTS.GRANTED &&
50
+ granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
51
+ PermissionsAndroid.RESULTS.GRANTED
52
+ );
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
70
57
  try {
71
- const { mediaDevices } = require("@livekit/react-native-webrtc");
72
- const stream = await mediaDevices.getUserMedia({ audio: true, video: true });
73
- for (const track of stream.getTracks()) track.stop();
74
- return true;
58
+ const native = require("@pickleball/rtmp-native").default;
59
+ if (typeof native?.requestPermissions === "function") {
60
+ return (await native.requestPermissions()) !== false;
61
+ }
75
62
  } catch {
76
- return false;
63
+ // Binary cũ chưa có hàm → AVFoundation tự hỏi lúc prepare.
77
64
  }
65
+ return true;
78
66
  },
79
- activateKeepAwake() {
80
- return require("expo-keep-awake").activateKeepAwakeAsync(
81
- "@pickleball/expo-sdk",
82
- );
83
- },
84
- deactivateKeepAwake() {
85
- return require("expo-keep-awake").deactivateKeepAwake(
86
- "@pickleball/expo-sdk",
87
- );
67
+ activateKeepAwake: () =>
68
+ Promise.resolve(
69
+ require("expo-keep-awake").activateKeepAwakeAsync("@pickleball/expo-sdk"),
70
+ ).catch(() => undefined),
71
+ // deactivate trả promise và REJECT khi activity đã chết (sau crash/reload) —
72
+ // không catch là thêm một "Uncaught (in promise)" gây nhiễu log.
73
+ deactivateKeepAwake: () => {
74
+ void Promise.resolve(
75
+ require("expo-keep-awake").deactivateKeepAwake("@pickleball/expo-sdk"),
76
+ ).catch(() => undefined);
88
77
  },
89
78
  };
90
79
  }
91
80
 
81
+ /** Runtime mặc định của SDK 1.0 = RTMP (LiveKit đã gỡ khỏi SDK — ADR-0002). */
82
+ export const createDefaultRuntime = createRtmpRuntime;
83
+
92
84
  export function createDefaultStorage(): SdkStorage {
93
85
  // Cài localStorage của expo-sqlite ở LẦN DÙNG ĐẦU, không phải lúc gọi factory:
94
86
  // app thường tạo storage ở module scope (root layout), và static-render web
@@ -174,19 +166,122 @@ export function createDefaultTelemetry(
174
166
  };
175
167
  }
176
168
 
169
+ /** roomFactory mặc định của SDK 1.0 = RTMP (fps/camera mặc định của native). */
177
170
  export function createDefaultRoomFactory(): EngineDependencies["roomFactory"] {
178
- return () => new LiveKitRoomAdapter(createNativeLiveKitBindings());
171
+ return createRtmpRoomFactory();
179
172
  }
180
173
 
181
174
  /**
182
- * Bindings cho mode device-rtmp. `require` động giống LiveKit ở trên: app nào
183
- * không cài @pickleball/rtmp-native thì chỉ lỗi khi thật sự chọn mode này,
184
- * không gãy lúc import SDK.
175
+ * Kênh poll lệnh cho phiên device-rtmp. URL suy từ telemetry endpoint
176
+ * (.../telemetry .../commands) cùng origin, cùng Bearer token, nên không
177
+ * cần thêm bất kỳ trường mới nào đi qua proxy của đối tác.
178
+ */
179
+ export function createDefaultCommandChannel(
180
+ fetchImplementation = globalThis.fetch,
181
+ timeoutMs = 10_000,
182
+ ): SessionCommandChannel {
183
+ const controllers = new Set<AbortController>();
184
+ return {
185
+ async poll(credentials, after): Promise<SessionCommandPollResult> {
186
+ const endpoint = credentials.endpoint.replace(/\/telemetry$/, "/commands");
187
+ const controller = new AbortController();
188
+ controllers.add(controller);
189
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
190
+ try {
191
+ const response = await fetchImplementation(endpoint, {
192
+ method: "POST",
193
+ headers: {
194
+ "Content-Type": "application/json",
195
+ Authorization: `Bearer ${credentials.token}`,
196
+ },
197
+ body: JSON.stringify({ after }),
198
+ signal: controller.signal,
199
+ redirect: "error",
200
+ });
201
+ if (!response.ok) {
202
+ if (typeof response.text === "function") await response.text();
203
+ throw Object.assign(new Error("Command poll rejected"), {
204
+ status: response.status,
205
+ });
206
+ }
207
+ const envelope = (await response.json()) as { data?: unknown };
208
+ return parseCommandPollResult(envelope?.data);
209
+ } finally {
210
+ clearTimeout(timeout);
211
+ controllers.delete(controller);
212
+ }
213
+ },
214
+ dispose() {
215
+ for (const controller of controllers) controller.abort();
216
+ controllers.clear();
217
+ },
218
+ };
219
+ }
220
+
221
+ function parseCommandPollResult(value: unknown): SessionCommandPollResult {
222
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
223
+ throw new Error("Command poll returned an invalid payload");
224
+ }
225
+ const record = value as Record<string, unknown>;
226
+ const status = record.status;
227
+ if (status !== "scheduled" && status !== "live" && status !== "ended") {
228
+ throw new Error("Command poll returned an invalid status");
229
+ }
230
+ const commands: SessionCommandRecord[] = [];
231
+ if (Array.isArray(record.commands)) {
232
+ for (const item of record.commands) {
233
+ if (item === null || typeof item !== "object") continue;
234
+ const command = item as Record<string, unknown>;
235
+ if (
236
+ typeof command.id !== "string" ||
237
+ (command.action !== "start" &&
238
+ command.action !== "stop" &&
239
+ command.action !== "end") ||
240
+ typeof command.issuedAt !== "number" ||
241
+ !Number.isFinite(command.issuedAt)
242
+ ) {
243
+ continue;
244
+ }
245
+ commands.push({
246
+ id: command.id,
247
+ action: command.action,
248
+ issuedAt: command.issuedAt,
249
+ });
250
+ }
251
+ }
252
+ return { commands, status };
253
+ }
254
+
255
+ /**
256
+ * Bindings native RTMP. `require` động: app chưa cài @pickleball/rtmp-native
257
+ * chỉ lỗi khi thật sự mở camera (UnsupportedRuntimeError rõ ràng), không gãy
258
+ * lúc import SDK — static-render web của expo-router vẫn nạp được module.
185
259
  */
186
260
  export function createNativeRtmpBindings(): NativeRtmpBindings {
187
261
  const native = require("@pickleball/rtmp-native").default;
262
+ const { Platform, PermissionsAndroid } = require("react-native");
188
263
  return {
189
264
  prepare: (options) => native.prepare(options),
265
+ // Android: xin quyền THUẦN, không mở camera như getUserMedia của WebRTC.
266
+ // Máy MediaTek (Xiaomi tanzanite) chỉ cho một camera mở cùng lúc — camera
267
+ // WebRTC chưa nhả (~400ms) là HaishinKit dính "Too many users (-87)" và
268
+ // preview đen. iOS không có giới hạn đó nên để engine fallback getUserMedia.
269
+ ...(Platform.OS === "android"
270
+ ? {
271
+ async requestPermissions() {
272
+ const granted = await PermissionsAndroid.requestMultiple([
273
+ PermissionsAndroid.PERMISSIONS.CAMERA,
274
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
275
+ ]);
276
+ return (
277
+ granted[PermissionsAndroid.PERMISSIONS.CAMERA] ===
278
+ PermissionsAndroid.RESULTS.GRANTED &&
279
+ granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
280
+ PermissionsAndroid.RESULTS.GRANTED
281
+ );
282
+ },
283
+ }
284
+ : {}),
190
285
  isLandscape() {
191
286
  const { width, height } = require("react-native").Dimensions.get("window");
192
287
  return width > height;
@@ -201,12 +296,22 @@ export function createNativeRtmpBindings(): NativeRtmpBindings {
201
296
  setVideoBitrate: (bps) => native.setVideoBitrate(bps),
202
297
  dispose: () => native.dispose(),
203
298
  addStatusListener: (listener) => native.addListener("onStatusChanged", listener),
299
+ addBandwidthListener: (listener) => native.addListener("onBandwidth", listener),
204
300
  };
205
301
  }
206
302
 
207
303
  /** roomFactory cho mode device-rtmp (thiết bị đẩy RTMP, không dùng LiveKit). */
208
- export function createRtmpRoomFactory(): EngineDependencies["roomFactory"] {
209
- return () => new RtmpRoomAdapter(createNativeRtmpBindings());
304
+ export function createRtmpRoomFactory(options?: {
305
+ /**
306
+ * fps capture + encode (24 tiết kiệm băng thông, 30 mặc định của native).
307
+ * 60 chỉ nên truyền khi `getCameraCapabilities().supportedFrameRates` có nó —
308
+ * native bỏ qua trong im lặng nếu ống kính đang gắn không chạy được.
309
+ */
310
+ frameRate?: 24 | 30 | 60;
311
+ /** Camera mở sẵn lúc prepare — thiếu = camera sau mặc định của native. */
312
+ cameraId?: string;
313
+ }): EngineDependencies["roomFactory"] {
314
+ return () => new RtmpRoomAdapter(createNativeRtmpBindings(), options);
210
315
  }
211
316
 
212
317
  /**
@@ -264,6 +369,21 @@ export function createDefaultDeviceMetrics(): NonNullable<
264
369
  /* expo-device chưa được cài */
265
370
  }
266
371
 
372
+ // Nhiệt máy + nhiệt pin: chỉ có đường native (không package Expo nào đọc
373
+ // được). Guard require như các nguồn khác — app không cài rtmp-native thì
374
+ // bỏ qua hai trường này thay vì gãy cả collector.
375
+ try {
376
+ const native = require("@pickleball/rtmp-native").default;
377
+ const health = await native.getDeviceHealth();
378
+ if (typeof health?.thermal === "number") metrics.thermal = health.thermal;
379
+ if (typeof health?.batteryTempC === "number") {
380
+ // Làm tròn 1 số lẻ: pin báo theo 1/10 °C, giữ nguyên là đủ chính xác.
381
+ metrics.batteryTempC = Math.round(health.batteryTempC * 10) / 10;
382
+ }
383
+ } catch (error) {
384
+ notes.push(`device health: ${String(error)}`);
385
+ }
386
+
267
387
  try {
268
388
  const NetInfo = require("@react-native-community/netinfo").default;
269
389
  const status = await NetInfo.fetch();
@@ -277,6 +397,42 @@ export function createDefaultDeviceMetrics(): NonNullable<
277
397
  ).toUpperCase()
278
398
  : status.type;
279
399
  }
400
+ // Chi tiết mạng cho việc truy vết sự cố tại sân: máy còn bám router của
401
+ // giải hay đã rớt sang 4G, sóng yếu tới mức nào.
402
+ //
403
+ // CỐ Ý không đọc `details.ssid`/`bssid`: hai field đó bị khoá sau quyền
404
+ // VỊ TRÍ (Android cần ACCESS_FINE_LOCATION + bật GPS; iOS cần entitlement
405
+ // Access WiFi Information). Xin quyền vị trí cho mỗi máy đặt ở sân chỉ để
406
+ // hiện tên WiFi là cái giá quá đắt — IP nội bộ dưới đây phân biệt được
407
+ // router mà không cần quyền nào.
408
+ const details = status?.details as
409
+ | {
410
+ ipAddress?: unknown;
411
+ strength?: unknown;
412
+ linkSpeed?: unknown;
413
+ frequency?: unknown;
414
+ }
415
+ | undefined;
416
+ if (typeof details?.ipAddress === "string" && details.ipAddress) {
417
+ metrics.localIp = details.ipAddress;
418
+ }
419
+ // strength: 0-100 (%). CHỈ Android — iOS không mở cường độ sóng cho app.
420
+ if (typeof details?.strength === "number" && Number.isFinite(details.strength)) {
421
+ metrics.signalPct = Math.round(details.strength);
422
+ }
423
+ if (typeof details?.linkSpeed === "number" && Number.isFinite(details.linkSpeed)) {
424
+ metrics.linkMbps = Math.round(details.linkSpeed);
425
+ }
426
+ // Tần số kênh (Android) — phân biệt 2.4/5 GHz không cần quyền vị trí.
427
+ // Chỉ đi vào telemetry PHIÊN (dataJson tự do); heartbeat thiết bị lọc
428
+ // theo METRIC_FIELDS nên không đụng validator server.
429
+ if (
430
+ typeof details?.frequency === "number" &&
431
+ Number.isFinite(details.frequency) &&
432
+ details.frequency > 0
433
+ ) {
434
+ metrics.freqMhz = Math.round(details.frequency);
435
+ }
280
436
  } catch {
281
437
  /* netinfo không khả dụng */
282
438
  }
package/src/plugin.ts CHANGED
@@ -4,12 +4,19 @@ import {
4
4
  withAndroidManifest,
5
5
  withDangerousMod,
6
6
  withInfoPlist,
7
- withPlugins,
8
7
  type ConfigPlugin,
9
8
  } from "expo/config-plugins";
10
9
  import { readdir, readFile, writeFile } from "node:fs/promises";
11
10
  import { join } from "node:path";
12
- import { PICKLEBALL_EXPO_SDK_VERSION } from "./version";
11
+
12
+ /**
13
+ * Version của SCHEMA plugin — tách khỏi version package có chủ ý. Giá trị này
14
+ * được inline vào dist/plugin.cjs, mà @expo/fingerprint băm file đó làm
15
+ * runtimeVersion của app fleet: gắn theo version package là MỖI lần bump SDK
16
+ * (kể cả patch JS-only) đổi fingerprint và toàn bộ máy ngoài sân mất OTA. Chỉ
17
+ * nâng khi plugin đổi hành vi native (permission, mod).
18
+ */
19
+ export const PLUGIN_SCHEMA_VERSION = "1";
13
20
 
14
21
  export interface PickleballLivePluginProps {
15
22
  cameraPermission?: string;
@@ -24,11 +31,17 @@ export interface PickleballLivePluginProps {
24
31
  * - voice: false → hiện chưa gate gì trong plugin (mic thuộc livestream);
25
32
  * nhận trước để tương thích về sau. Muốn loại hẳn native khỏi binary, thêm
26
33
  * expo.autolinking.exclude trong package.json của app (xem README).
34
+ * - livekit: KHÔNG còn tác dụng từ 1.0 (SDK RTMP-only, không compose plugin
35
+ * LiveKit nữa). Vẫn nhận để app.config cũ của fleet không phải sửa — sửa
36
+ * là đổi fingerprint OTA.
27
37
  */
28
38
  modules?: {
29
39
  remote?: boolean;
30
40
  voice?: boolean;
41
+ /** @deprecated bị bỏ qua từ 1.0 */
42
+ livekit?: boolean;
31
43
  };
44
+ /** @deprecated bị bỏ qua từ 1.0 (SDK không còn LiveKit). */
32
45
  liveKit?: {
33
46
  android?: { audioType?: string; enableScreenShareService?: boolean };
34
47
  ios?: { enableMultitaskingCameraAccess?: boolean };
@@ -38,6 +51,9 @@ export interface PickleballLivePluginProps {
38
51
  const CORE_ANDROID_PERMISSIONS = [
39
52
  "android.permission.ACCESS_NETWORK_STATE",
40
53
  "android.permission.CAMERA",
54
+ "android.permission.FOREGROUND_SERVICE",
55
+ "android.permission.FOREGROUND_SERVICE_CAMERA",
56
+ "android.permission.FOREGROUND_SERVICE_MICROPHONE",
41
57
  "android.permission.INTERNET",
42
58
  "android.permission.MODIFY_AUDIO_SETTINGS",
43
59
  "android.permission.RECORD_AUDIO",
@@ -98,9 +114,6 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
98
114
  ? [...CORE_ANDROID_PERMISSIONS, ...REMOTE_ANDROID_PERMISSIONS]
99
115
  : [...CORE_ANDROID_PERMISSIONS];
100
116
 
101
- config = withPlugins(config, [
102
- ["@livekit/react-native-expo-plugin", props?.liveKit],
103
- ]);
104
117
  config.ios = { ...config.ios, bitcode: false };
105
118
  config = withInfoPlist(config, (mod) => {
106
119
  mod.modResults.NSCameraUsageDescription = cameraPermission;
@@ -162,5 +175,5 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
162
175
  export default createRunOncePlugin(
163
176
  withPickleballLive,
164
177
  "@pickleball/expo-sdk",
165
- PICKLEBALL_EXPO_SDK_VERSION,
178
+ PLUGIN_SCHEMA_VERSION,
166
179
  );
package/src/provider.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
1
+ import React, { useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
2
  import { AppState } from "react-native";
3
3
  import type { BootstrapInput, LivestreamSessionProvider, StartLivestreamInput, EndReason } from "./contracts";
4
4
  import { ExpoSdkConfigurationError, type PickleballExpoError } from "./errors";
@@ -10,9 +10,12 @@ import {
10
10
  type SdkNetwork,
11
11
  type SdkRuntime,
12
12
  type SdkStorage,
13
+ type SessionCommandChannel,
14
+ type SessionCommandSubscription,
13
15
  type TelemetrySink,
14
16
  } from "./engine";
15
17
  import {
18
+ createDefaultCommandChannel,
16
19
  createDefaultDeviceMetrics,
17
20
  createDefaultNetwork,
18
21
  createDefaultRoomFactory,
@@ -64,10 +67,33 @@ export interface PickleballLiveProviderProps {
64
67
  children?: ReactNode;
65
68
  sessionProvider: LivestreamSessionProvider;
66
69
  bootstrapInput: BootstrapInput;
70
+ /**
71
+ * Phiên phải SỐNG TIẾP khi app biến mất giữa chừng (sụp nguồn, bị vuốt tắt,
72
+ * component remount), để lần mở sau nhận lại đúng phiên cũ — giữ nguyên link
73
+ * xem và VOD liền một mạch.
74
+ *
75
+ * Mặc định false = hành vi cũ: mở app lại là dọn sạch phiên cũ. App gác sân bật
76
+ * cờ này khi máy đang được gán sân; máy không gán sân vẫn dọn như cũ để không
77
+ * để lại phiên treo trong các ca ngoài giải.
78
+ *
79
+ * Nhận boolean HOẶC hàm (được phép async). App nên truyền HÀM ĐỌC THẲNG ĐĨA:
80
+ * engine hỏi cờ này đúng lúc khởi động, thường TRƯỚC khi state React kịp
81
+ * restore — truyền boolean suy từ state là dính race keep=false oan (đã dính
82
+ * thật 18/08/2026, sinh phiên chờ thứ hai đè phiên live cũ).
83
+ */
84
+ keepSessionOnRestart?: boolean | (() => boolean | Promise<boolean>);
67
85
  runtime?: SdkRuntime;
68
86
  storage?: SdkStorage;
69
87
  network?: SdkNetwork;
70
88
  telemetry?: TelemetrySink;
89
+ /** Kênh poll lệnh cho mode device-rtmp — mặc định suy từ telemetry endpoint. */
90
+ commandChannel?: SessionCommandChannel;
91
+ /**
92
+ * Kênh nhận lệnh qua websocket (first-party nối thẳng Convex). Có thì lệnh về
93
+ * tức thì và poll tụt về nhịp reconcile chậm — cắt phần lớn function call của
94
+ * đường poll. Thiếu (máy đối tác qua proxy) thì chỉ dùng poll HTTP như cũ.
95
+ */
96
+ commandSubscription?: SessionCommandSubscription;
71
97
  roomFactory?: () => RoomAdapter;
72
98
  deviceMetrics?: EngineDependencies["deviceMetrics"];
73
99
  theme?: Partial<LivestreamTheme>;
@@ -121,6 +147,31 @@ const LivestreamContext = React.createContext<LivestreamContextValue | null>(nul
121
147
 
122
148
  export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
123
149
  const engineRef = useRef<LivestreamEngine | null>(null);
150
+ const keepSessionRef = useRef(props.keepSessionOnRestart);
151
+ keepSessionRef.current = props.keepSessionOnRestart;
152
+ // dispose() phải quyết NGAY (sync) có giữ phiên hay không, còn prop dạng hàm
153
+ // thì async — resolve sẵn vào ref này. Khi prop là hàm mà chưa có kết quả,
154
+ // nghiêng về GIỮ: dọn oan là mất phiên vĩnh viễn (link đổi, VOD tách đôi),
155
+ // giữ oan thì lưới server (1 giờ live / 30 phút standby) dọn hộ.
156
+ const resolvedKeepRef = useRef(typeof props.keepSessionOnRestart === "function");
157
+ useEffect(() => {
158
+ const value = props.keepSessionOnRestart;
159
+ if (typeof value !== "function") {
160
+ resolvedKeepRef.current = value === true;
161
+ return;
162
+ }
163
+ resolvedKeepRef.current = true;
164
+ let cancelled = false;
165
+ void Promise.resolve()
166
+ .then(() => value())
167
+ .then((keep) => {
168
+ if (!cancelled) resolvedKeepRef.current = keep === true;
169
+ })
170
+ .catch(() => undefined);
171
+ return () => {
172
+ cancelled = true;
173
+ };
174
+ }, [props.keepSessionOnRestart]);
124
175
  const hostRef = useRef<LivestreamHostContextValue | null>(null);
125
176
  const effectGenerationRef = useRef(0);
126
177
  if (!engineRef.current) {
@@ -132,8 +183,19 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
132
183
  storage: props.storage ?? createDefaultStorage(),
133
184
  network: props.network ?? createDefaultNetwork(),
134
185
  telemetry: props.telemetry ?? createDefaultTelemetry(),
186
+ commandChannel: props.commandChannel ?? createDefaultCommandChannel(),
187
+ ...(props.commandSubscription
188
+ ? { commandSubscription: props.commandSubscription }
189
+ : {}),
135
190
  deviceMetrics,
136
191
  roomFactory: props.roomFactory ?? createDefaultRoomFactory(),
192
+ // Đọc qua ref để engine luôn thấy giá trị MỚI NHẤT: engine chỉ dựng một lần
193
+ // (engineRef), còn prop thì đổi theo việc máy được gán/gỡ sân. Prop dạng
194
+ // hàm được gọi xuyên qua (engine tự await) — đường cho app đọc thẳng đĩa.
195
+ keepSessionOnRestart: () => {
196
+ const value = keepSessionRef.current;
197
+ return typeof value === "function" ? value() : value === true;
198
+ },
137
199
  };
138
200
  engineRef.current = new LivestreamEngine(dependencies);
139
201
  hostRef.current = {
@@ -161,7 +223,7 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
161
223
  subscription.remove();
162
224
  queueMicrotask(() => {
163
225
  if (effectGenerationRef.current === effectGeneration) {
164
- void engine.dispose();
226
+ void engine.dispose({ keepSession: resolvedKeepRef.current });
165
227
  }
166
228
  });
167
229
  };
@@ -171,22 +233,36 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
171
233
  if (snapshot.error) props.onError?.(snapshot.error);
172
234
  }, [props.onError, snapshot.error]);
173
235
 
174
- const value: LivestreamContextValue = {
175
- ...snapshot,
176
- locale: props.locale ?? "vi",
177
- theme: { ...defaultLivestreamTheme, ...props.theme },
178
- prepare: () => engine.prepare(),
179
- start: (input, options) => engine.start(input, options),
180
- startPublishing: () => engine.startPublishing(),
181
- stopPublishing: () => engine.stopPublishing(),
182
- stop: (reason) => engine.stop(reason),
183
- retry: () => engine.retry(),
184
- switchCamera: () => engine.switchCamera(),
185
- setMicrophoneEnabled: (enabled) => engine.setMicrophoneEnabled(enabled),
186
- setCaptureOrientation: (orientation) =>
187
- engine.setCaptureOrientation(orientation),
188
- selectCamera: (deviceId) => engine.selectCamera(deviceId),
189
- };
236
+ // Hành động ổn định theo engine (không dựng lại mỗi render): các effect phía
237
+ // trên (AutoResume, useStandbySession, app đối tác) đặt chúng vào dependency
238
+ // dựng lại mỗi render là effect huỷ/chạy lại theo nhịp render, timer thử
239
+ // lại không bao giờ kịp nổ.
240
+ const actions = useMemo(
241
+ () => ({
242
+ prepare: () => engine.prepare(),
243
+ start: (input: StartLivestreamInput, options?: { autoPublish?: boolean }) =>
244
+ engine.start(input, options),
245
+ startPublishing: () => engine.startPublishing(),
246
+ stopPublishing: () => engine.stopPublishing(),
247
+ stop: (reason?: EndReason) => engine.stop(reason),
248
+ retry: () => engine.retry(),
249
+ switchCamera: () => engine.switchCamera(),
250
+ setMicrophoneEnabled: (enabled: boolean) => engine.setMicrophoneEnabled(enabled),
251
+ setCaptureOrientation: (orientation: "portrait" | "landscape") =>
252
+ engine.setCaptureOrientation(orientation),
253
+ selectCamera: (deviceId: string) => engine.selectCamera(deviceId),
254
+ }),
255
+ [engine],
256
+ );
257
+ const locale = props.locale ?? "vi";
258
+ const theme = useMemo(
259
+ () => ({ ...defaultLivestreamTheme, ...props.theme }),
260
+ [props.theme],
261
+ );
262
+ const value = useMemo<LivestreamContextValue>(
263
+ () => ({ ...snapshot, locale, theme, ...actions }),
264
+ [snapshot, locale, theme, actions],
265
+ );
190
266
  return (
191
267
  <LivestreamHostContext.Provider value={hostRef.current}>
192
268
  <LivestreamContext.Provider value={value}>
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Kênh lệnh realtime qua websocket Convex cho app dùng camera grant.
3
+ *
4
+ * Grant có `convexUrl` (origin .convex.cloud) → truyền
5
+ * `commandSubscription={createGrantCommandSubscription(() => grant?.convexUrl)}`
6
+ * vào PickleballLiveProvider: lệnh start/stop/end về tức thì và poll HTTP tụt
7
+ * về nhịp đối chiếu 30s (thay vì 2s/4s — ~3.600 call/giờ/máy).
8
+ *
9
+ * Subpath riêng vì kéo theo `convex`; root entry giữ gọn. `./device-agent` vẫn
10
+ * re-export cùng hàm cho app fleet.
11
+ */
12
+ import type { SdkTelemetryCredentials, SessionCommandRecord } from "../contracts";
13
+ import {
14
+ createConvexCommandSubscription,
15
+ createConvexDeviceRealtime,
16
+ type ConvexDeviceRealtime,
17
+ } from "../device-agent/convex-command-subscription";
18
+ import type { SessionCommandSubscription } from "../engine";
19
+
20
+ export { createConvexCommandSubscription, createConvexDeviceRealtime, type ConvexDeviceRealtime };
21
+
22
+ /**
23
+ * Subscription cho app dùng camera grant: URL Convex nằm TRONG grant
24
+ * (`grant.convexUrl`) nên chỉ biết sau khi start — đọc URL lúc engine subscribe
25
+ * (ngay sau khi vào phiên), dựng MỘT client mỗi origin và tái dùng qua các
26
+ * phiên. Không có URL (deployment không lộ origin chuẩn) → không subscribe,
27
+ * engine giữ poll HTTP như cũ.
28
+ *
29
+ * Dùng với createCameraGrantProvider: `() => provider.getActiveGrant()?.convexUrl`.
30
+ */
31
+ export function createGrantCommandSubscription(
32
+ getConvexUrl: () => string | null | undefined,
33
+ ): SessionCommandSubscription {
34
+ const clients = new Map<string, ConvexDeviceRealtime>();
35
+ return {
36
+ subscribe(
37
+ credentials: SdkTelemetryCredentials,
38
+ sessionId: string,
39
+ onCommands: (commands: SessionCommandRecord[]) => void,
40
+ onError?: (error: Error) => void,
41
+ ) {
42
+ const url = getConvexUrl();
43
+ if (!url) return () => undefined;
44
+ let realtime = clients.get(url);
45
+ if (!realtime) {
46
+ realtime = createConvexDeviceRealtime(url);
47
+ clients.set(url, realtime);
48
+ }
49
+ return realtime.commandSubscription.subscribe(credentials, sessionId, onCommands, onError);
50
+ },
51
+ dispose() {
52
+ for (const realtime of clients.values()) realtime.dispose();
53
+ clients.clear();
54
+ },
55
+ };
56
+ }