@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
@@ -186,7 +186,13 @@ function isSessionGoneError(error) {
186
186
 
187
187
  // src/engine.ts
188
188
  var PENDING_END_KEY = "@pickleball/expo-sdk/pending-end";
189
+ var CAPTURE_ORIENTATION_KEY = "@pickleball/expo-sdk/capture-orientation";
189
190
  var DEFAULT_STANDBY_TIMEOUT_MS = 15 * 6e4;
191
+ var TOKEN_REFRESH_MARGIN_MS = 2 * 6e4;
192
+ var COMMAND_POLL_STANDBY_MS = 2e3;
193
+ var COMMAND_POLL_LIVE_MS = 4e3;
194
+ var COMMAND_POLL_MAX_BACKOFF_MS = 3e4;
195
+ var COMMAND_POLL_RECONCILE_MS = 3e4;
190
196
  var activeStates = /* @__PURE__ */ new Set([
191
197
  "standby",
192
198
  "live",
@@ -234,7 +240,8 @@ var LivestreamEngine = class {
234
240
  cameras: [],
235
241
  selectedCameraId: null,
236
242
  elapsedMs: 0,
237
- isOnline: dependencies.network.isOnline()
243
+ isOnline: dependencies.network.isOnline(),
244
+ adoptableSessionId: null
238
245
  };
239
246
  }
240
247
  room;
@@ -260,10 +267,23 @@ var LivestreamEngine = class {
260
267
  backgroundTimer;
261
268
  standbyTimer;
262
269
  reconnectTimer;
270
+ redialTimer;
271
+ /** Đang trong vòng quay số lại — xem doRetry() để biết vì sao cần cờ này. */
272
+ redialing = false;
263
273
  heartbeatTimer;
264
274
  elapsedTimer;
265
275
  tokenRefreshTimer;
266
276
  maxSessionTimer;
277
+ commandPollTimer;
278
+ /** Huỷ subscription websocket lệnh của phiên hiện tại (nếu đang mở). */
279
+ commandUnsubscribe;
280
+ /** Khoá phiên+token đang subscribe — đổi thì resubscribe (token refresh/đổi phiên). */
281
+ commandSubscriptionKey;
282
+ /** Websocket đã đẩy được ít nhất một lần → coi là sống, cho phép hạ nhịp poll. */
283
+ commandSubscriptionActive = false;
284
+ /** issuedAt của lệnh cuối đã xử lý — server chỉ trả lệnh mới hơn mốc này. */
285
+ commandCursor = 0;
286
+ commandPollBackoffMs = 0;
267
287
  grant;
268
288
  startedAt;
269
289
  /** Đang publish track (live) hay chỉ ngồi trong phòng (standby). */
@@ -348,12 +368,19 @@ var LivestreamEngine = class {
348
368
  throw new UnsupportedSdkError();
349
369
  }
350
370
  this.update({ config: bootstrap.config });
351
- const permitted = await this.dependencies.runtime.requestPermissions();
371
+ const room = this.ensureRoom();
372
+ const permitted = room.requestPermissions ? await room.requestPermissions() : await this.dependencies.runtime.requestPermissions();
352
373
  this.ensureOperation(generation);
353
374
  if (!permitted) {
354
375
  throw new PermissionDeniedError();
355
376
  }
356
- const room = this.ensureRoom();
377
+ if (!this.snapshot.captureOrientation) {
378
+ const stored = await this.dependencies.storage.getItem(CAPTURE_ORIENTATION_KEY).catch(() => null);
379
+ this.ensureOperation(generation);
380
+ if (stored === "portrait" || stored === "landscape") {
381
+ this.update({ captureOrientation: stored });
382
+ }
383
+ }
357
384
  if (this.snapshot.captureOrientation) {
358
385
  await room.setCaptureOrientation?.(this.snapshot.captureOrientation);
359
386
  }
@@ -415,7 +442,7 @@ var LivestreamEngine = class {
415
442
  this.transition("connecting");
416
443
  this.ensureBackgroundGraceTimer();
417
444
  try {
418
- await this.ensurePendingEndResolved();
445
+ await this.ensurePendingEndResolved(input.externalSessionId);
419
446
  this.ensureOperation(generation);
420
447
  } catch (error) {
421
448
  if (!this.isOperationCurrent(generation) || error instanceof StaleOperationError) {
@@ -440,7 +467,12 @@ var LivestreamEngine = class {
440
467
  );
441
468
  }
442
469
  this.ownedSessionId = grant.sessionId;
470
+ if (this.snapshot.adoptableSessionId === grant.sessionId) {
471
+ this.update({ adoptableSessionId: null });
472
+ }
443
473
  this.grant = grant;
474
+ this.commandCursor = 0;
475
+ this.commandPollBackoffMs = 0;
444
476
  this.liveTimersStarted = false;
445
477
  this.keepAwakeActive = false;
446
478
  this.publishing = autoPublish;
@@ -533,7 +565,11 @@ var LivestreamEngine = class {
533
565
  if (!sessionId || !this.grant) throw new Error("Missing session grant");
534
566
  const activeGrant = this.grant;
535
567
  try {
536
- await this.refreshCredentials(generation);
568
+ const expiresAt = this.grant?.tokenExpiresAt ?? 0;
569
+ const tokenStillFresh = expiresAt > 0 && expiresAt - Date.now() >= TOKEN_REFRESH_MARGIN_MS;
570
+ if (!this.redialing || !tokenStillFresh) {
571
+ await this.refreshCredentials(generation);
572
+ }
537
573
  this.ensureOperation(generation);
538
574
  await this.requireRoom().connect({
539
575
  serverUrl: this.grant.serverUrl,
@@ -549,6 +585,16 @@ var LivestreamEngine = class {
549
585
  await this.abandonGrant(activeGrant);
550
586
  return;
551
587
  }
588
+ const httpStatus = error.httpStatus;
589
+ if (httpStatus === 404 || httpStatus === 409) {
590
+ this.stopRedialing();
591
+ void this.stop("user");
592
+ return;
593
+ }
594
+ if (this.redialing && this.snapshot.sessionId) {
595
+ this.update({ error: normalizeError(error) });
596
+ return;
597
+ }
552
598
  this.fail(error);
553
599
  }
554
600
  }
@@ -592,6 +638,7 @@ var LivestreamEngine = class {
592
638
  if (!this.isOperationCurrent(generation)) return;
593
639
  }
594
640
  this.update({ captureOrientation: orientation });
641
+ void this.dependencies.storage.setItem(CAPTURE_ORIENTATION_KEY, orientation).catch(() => void 0);
595
642
  }
596
643
  async setMicrophoneEnabled(enabled) {
597
644
  const generation = this.operationGeneration;
@@ -604,25 +651,38 @@ var LivestreamEngine = class {
604
651
  if (event.type === "command") {
605
652
  if (event.action === "start") void this.startPublishing();
606
653
  else if (event.action === "stop") void this.stopPublishing();
654
+ else if (event.action === "end") void this.stop("user");
607
655
  return;
608
656
  }
609
657
  if (event.type === "reconnecting" && (this.snapshot.state === "live" || this.snapshot.state === "standby")) {
610
658
  this.transition("reconnecting");
611
659
  void this.sendTelemetry("reconnect", { phase: "started" });
612
- const timeout = this.snapshot.config?.reconnectTimeoutMs ?? 3e4;
660
+ if (this.room?.needsManualReconnect) {
661
+ this.clearReconnectTimer();
662
+ this.scheduleRedial(0);
663
+ return;
664
+ }
665
+ const configured = this.snapshot.config?.reconnectTimeoutMs ?? 3e4;
613
666
  this.clearReconnectTimer();
614
667
  this.reconnectTimer = setTimeout(() => {
615
668
  void this.stop("network_timeout");
616
- }, timeout);
669
+ }, configured);
617
670
  return;
618
671
  }
619
672
  if (event.type === "reconnected" && this.snapshot.state === "reconnecting") {
620
673
  this.clearReconnectTimer();
674
+ this.stopRedialing();
621
675
  this.transition(this.publishing ? "live" : "standby");
622
676
  void this.sendTelemetry("reconnect", { phase: "recovered" });
623
677
  return;
624
678
  }
625
- if (event.type === "media-error") this.recordError(event.error);
679
+ if (event.type === "media-error") {
680
+ if (this.room?.needsManualReconnect && activeStates.has(this.snapshot.state)) {
681
+ this.handleRoomEvent({ type: "reconnecting" });
682
+ return;
683
+ }
684
+ this.recordError(event.error);
685
+ }
626
686
  if (event.type === "disconnected" && activeStates.has(this.snapshot.state)) {
627
687
  this.handleRoomEvent({ type: "reconnecting" });
628
688
  }
@@ -651,12 +711,21 @@ var LivestreamEngine = class {
651
711
  }
652
712
  }, grace);
653
713
  }
654
- dispose() {
714
+ /**
715
+ * `keepSession` = engine bị tháo nhưng phiên phải sống tiếp để lần mở app sau
716
+ * nhận lại. Mặc định false: giữ NGUYÊN hành vi cũ cho mọi SDK đối tác.
717
+ *
718
+ * Lưu ý dispose KHÔNG BAO GIỜ là đường của "người dùng bấm dừng" — nút dừng đi
719
+ * qua doStop, mà doStop xoá ownedSessionId trước, nên tới đây sessionId đã là
720
+ * undefined. Vì vậy nhánh end ở đây luôn là teardown NGOÀI Ý MUỐN (app bị giết,
721
+ * component remount), đúng chỗ cần giữ phiên.
722
+ */
723
+ dispose(options) {
655
724
  if (this.disposePromise) return this.disposePromise;
656
725
  if (this.disposed) return Promise.resolve();
657
726
  this.disposed = true;
658
727
  this.operationGeneration += 1;
659
- const sessionId = this.ownedSessionId;
728
+ const sessionId = options?.keepSession === true ? void 0 : this.ownedSessionId;
660
729
  let releaseEnd = () => void 0;
661
730
  const endBarrier = new Promise((resolve) => {
662
731
  releaseEnd = resolve;
@@ -665,6 +734,7 @@ var LivestreamEngine = class {
665
734
  const roomCleanup = this.releaseRoom(termination?.markerReady);
666
735
  this.clearTimers();
667
736
  this.dependencies.telemetry?.dispose?.();
737
+ this.dependencies.commandChannel?.dispose?.();
668
738
  this.pendingCriticalTelemetry = void 0;
669
739
  this.networkUnsubscribe();
670
740
  this.listeners.clear();
@@ -701,10 +771,36 @@ var LivestreamEngine = class {
701
771
  await this.dependencies.storage.removeItem(PENDING_END_KEY);
702
772
  this.pendingReplayError = void 0;
703
773
  }
774
+ /**
775
+ * Phiên tưởng đáng nhận lại hoá ra đã chết phía server → BỎ dấu để máy arm
776
+ * phiên mới được.
777
+ *
778
+ * Thiếu hàm này là máy kẹt cứng: dấu vẫn nằm đó chặn mọi lượt start (đúng vai
779
+ * trò chống phiên song song của nó), mà lượt start duy nhất được cho qua lại là
780
+ * lượt nhận lại chính cái phiên đã chết — không bao giờ thành công. Đã dính
781
+ * thật 18/08/2026: lưới standby 30 phút kết thúc phiên trong lúc máy ngủ, mở
782
+ * lại thì app đứng ở "error" vĩnh viễn.
783
+ */
784
+ async abandonAdoptableSession(sessionId) {
785
+ if (this.snapshot.adoptableSessionId !== sessionId) return;
786
+ try {
787
+ await this.dependencies.storage.removeItem(PENDING_END_KEY);
788
+ } catch {
789
+ }
790
+ this.update({ adoptableSessionId: null });
791
+ }
704
792
  async recoverStartupSession() {
705
793
  let pending = await this.readPendingEnd();
706
794
  if (!pending || this.ownsInMemorySession()) return;
707
795
  if (pending.pendingEnd === null) {
796
+ const keep = await this.dependencies.keepSessionOnRestart?.() === true;
797
+ console.warn(
798
+ `[picklive] khoi dong: phien ${pending.sessionId} chua duoc dung co chu y -> ${keep ? "GIU de nhan lai" : "ket thuc"}`
799
+ );
800
+ if (keep) {
801
+ this.update({ adoptableSessionId: pending.sessionId });
802
+ return;
803
+ }
708
804
  pending = { sessionId: pending.sessionId, pendingEnd: "error" };
709
805
  await this.dependencies.storage.setItem(
710
806
  PENDING_END_KEY,
@@ -723,10 +819,21 @@ var LivestreamEngine = class {
723
819
  await this.dependencies.storage.removeItem(PENDING_END_KEY);
724
820
  this.pendingReplayError = void 0;
725
821
  }
726
- async ensurePendingEndResolved() {
822
+ /**
823
+ * Dấu phiên cũ chặn thao tác mới cho tới khi nó được giải quyết.
824
+ *
825
+ * `externalSessionId` = id lượt start đang xin đi qua; bỏ trống nghĩa là
826
+ * prepare (mở camera), vốn phải luôn được phép khi đang định nhận lại — không
827
+ * mở được camera thì không nhận lại được, và đó chính là bẫy làm máy kẹt cứng ở
828
+ * màn lỗi "The previous livestream is still pending end".
829
+ */
830
+ async ensurePendingEndResolved(externalSessionId) {
727
831
  await this.initialize();
728
832
  const pending = await this.readPendingEnd();
729
833
  if (!pending) return;
834
+ if (pending.pendingEnd === null && this.snapshot.adoptableSessionId === pending.sessionId && (externalSessionId === void 0 || externalSessionId === `resume-${pending.sessionId}`)) {
835
+ return;
836
+ }
730
837
  if (!this.dependencies.network.isOnline()) {
731
838
  throw new NetworkUnavailableError(
732
839
  "Connect to the network to finish the previous livestream"
@@ -814,6 +921,8 @@ var LivestreamEngine = class {
814
921
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
815
922
  if (this.maxSessionTimer) clearTimeout(this.maxSessionTimer);
816
923
  this.clearReconnectTimer();
924
+ this.stopCommandPolling();
925
+ this.stopCommandSubscription();
817
926
  this.backgroundTimer = void 0;
818
927
  this.heartbeatTimer = void 0;
819
928
  this.elapsedTimer = void 0;
@@ -822,10 +931,179 @@ var LivestreamEngine = class {
822
931
  this.sessionDeadline = void 0;
823
932
  this.liveTimersStarted = false;
824
933
  }
934
+ /**
935
+ * Quay số lại với backoff cho adapter cần reconnect thủ công. Mỗi nhịp gọi
936
+ * retry() (refresh credentials + room.connect + enterSession). Thành công →
937
+ * enterSession phát "publishing" → nhánh "reconnected" dọn cả hai timer.
938
+ * Thất bại → doRetry() đưa state về "error" (vẫn còn sessionId) và nhịp sau
939
+ * retry() lại kéo về "reconnecting" — lặp tới khi doom timer 30s kết liễu.
940
+ * Backoff thưa dần để không dội server khi ingest thật sự sập.
941
+ */
942
+ scheduleRedial(attempt) {
943
+ this.clearRedialTimer();
944
+ this.redialing = true;
945
+ if (!this.dependencies.network.isOnline()) return;
946
+ const delays = [0, 1e3, 2e3, 4e3, 8e3, 15e3, 3e4];
947
+ const delay = delays[Math.min(attempt, delays.length - 1)];
948
+ this.redialTimer = setTimeout(() => {
949
+ if (this.disposed) return;
950
+ const s = this.snapshot.state;
951
+ if (s !== "reconnecting" && !(s === "error" && this.snapshot.sessionId)) {
952
+ return;
953
+ }
954
+ void this.retry().catch(() => {
955
+ }).finally(() => {
956
+ if (this.disposed) return;
957
+ const after = this.snapshot.state;
958
+ if (after === "reconnecting" || after === "error" && this.snapshot.sessionId) {
959
+ this.scheduleRedial(attempt + 1);
960
+ }
961
+ });
962
+ }, delay);
963
+ }
964
+ clearRedialTimer() {
965
+ if (this.redialTimer) clearTimeout(this.redialTimer);
966
+ this.redialTimer = void 0;
967
+ }
968
+ stopRedialing() {
969
+ this.redialing = false;
970
+ this.clearRedialTimer();
971
+ }
825
972
  clearReconnectTimer() {
826
973
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
827
974
  this.reconnectTimer = void 0;
828
975
  }
976
+ /**
977
+ * Poll lệnh chỉ khi phiên là device-rtmp (grant rtmp://) và đang standby/
978
+ * live. Ngoài hai trạng thái đó không có gì để điều khiển: connecting chưa
979
+ * có phiên chạy, reconnecting đã có reconnect timer lo, ending/ended thì thôi.
980
+ */
981
+ commandPollingWanted() {
982
+ const grant = this.grant;
983
+ return Boolean(
984
+ this.dependencies.commandChannel && grant && /^rtmps?:\/\//i.test(grant.serverUrl) && // "reconnecting" PHẢI có trong danh sách này. Sau khi bỏ trần 75 giây,
985
+ // máy có thể quay số hàng giờ; nếu trong lúc đó trọng tài kết thúc trận
986
+ // hoặc nhả sân, đây là đường duy nhất để máy biết mà thôi quay số vào một
987
+ // phiên đã chết (poll trả status "ended" → nhánh ở pollSessionCommands).
988
+ // Trước đây chỉ poll ở standby/live nên máy mất mạng là mù hoàn toàn.
989
+ activeStates.has(this.snapshot.state)
990
+ );
991
+ }
992
+ syncCommandPolling() {
993
+ if (this.disposed || !this.commandPollingWanted()) {
994
+ this.stopCommandPolling();
995
+ this.stopCommandSubscription();
996
+ return;
997
+ }
998
+ this.syncCommandSubscription();
999
+ if (!this.commandPollTimer) this.scheduleCommandPoll(0);
1000
+ }
1001
+ /**
1002
+ * Mở/đổi websocket nhận lệnh cho phiên hiện tại. Chỉ máy first-party có
1003
+ * dependency này (đối tác qua proxy thì undefined → chỉ poll). Resubscribe
1004
+ * khi đổi phiên HOẶC token (refresh) vì cả hai đi vào khoá.
1005
+ */
1006
+ syncCommandSubscription() {
1007
+ const subscription = this.dependencies.commandSubscription;
1008
+ const grant = this.grant;
1009
+ if (!subscription || !grant) return;
1010
+ const key = `${grant.sessionId}:${grant.telemetry.token}`;
1011
+ if (this.commandSubscriptionKey === key) return;
1012
+ this.stopCommandSubscription();
1013
+ this.commandSubscriptionKey = key;
1014
+ const sessionId = grant.sessionId;
1015
+ this.commandUnsubscribe = subscription.subscribe(
1016
+ grant.telemetry,
1017
+ sessionId,
1018
+ (commands) => {
1019
+ if (this.disposed || this.grant?.sessionId !== sessionId) return;
1020
+ this.commandSubscriptionActive = true;
1021
+ this.applyCommands(commands);
1022
+ },
1023
+ () => {
1024
+ this.commandSubscriptionActive = false;
1025
+ }
1026
+ );
1027
+ }
1028
+ stopCommandSubscription() {
1029
+ if (this.commandUnsubscribe) {
1030
+ try {
1031
+ this.commandUnsubscribe();
1032
+ } catch {
1033
+ }
1034
+ }
1035
+ this.commandUnsubscribe = void 0;
1036
+ this.commandSubscriptionKey = void 0;
1037
+ this.commandSubscriptionActive = false;
1038
+ }
1039
+ stopCommandPolling() {
1040
+ if (this.commandPollTimer) clearTimeout(this.commandPollTimer);
1041
+ this.commandPollTimer = void 0;
1042
+ this.commandPollBackoffMs = 0;
1043
+ }
1044
+ scheduleCommandPoll(delayMs) {
1045
+ this.commandPollTimer = setTimeout(() => {
1046
+ this.commandPollTimer = void 0;
1047
+ void this.pollSessionCommands();
1048
+ }, delayMs);
1049
+ this.commandPollTimer.unref?.();
1050
+ }
1051
+ commandPollInterval() {
1052
+ if (this.commandSubscriptionActive) return COMMAND_POLL_RECONCILE_MS;
1053
+ return this.snapshot.state === "live" ? COMMAND_POLL_LIVE_MS : COMMAND_POLL_STANDBY_MS;
1054
+ }
1055
+ /**
1056
+ * Áp lệnh từ poll HOẶC subscription vào cùng một state machine. Subscription
1057
+ * trả cả lệnh CŨ (after:0) nên phải lọc theo con trỏ ở đây; poll đã lọc
1058
+ * server-side (after=cursor) nên bước lọc này vô hại với nó.
1059
+ */
1060
+ applyCommands(commands) {
1061
+ for (const command of commands) {
1062
+ if (command.issuedAt <= this.commandCursor) continue;
1063
+ this.commandCursor = command.issuedAt;
1064
+ this.handleRoomEvent({ type: "command", action: command.action });
1065
+ }
1066
+ }
1067
+ async pollSessionCommands() {
1068
+ if (!this.commandPollingWanted()) return;
1069
+ const channel = this.dependencies.commandChannel;
1070
+ const grant = this.grant;
1071
+ if (!channel || !grant) return;
1072
+ const sessionId = grant.sessionId;
1073
+ let result = null;
1074
+ try {
1075
+ result = await channel.poll(grant.telemetry, this.commandCursor);
1076
+ this.commandPollBackoffMs = 0;
1077
+ } catch (error) {
1078
+ const status = error?.status;
1079
+ if (status === 401) {
1080
+ void this.refreshCredentials().catch(() => void 0);
1081
+ }
1082
+ if (status === 409 && this.grant?.sessionId === sessionId) {
1083
+ void this.stop("user");
1084
+ return;
1085
+ }
1086
+ this.commandPollBackoffMs = Math.min(
1087
+ COMMAND_POLL_MAX_BACKOFF_MS,
1088
+ Math.max(this.commandPollInterval(), this.commandPollBackoffMs * 2)
1089
+ );
1090
+ }
1091
+ if (this.disposed || this.grant?.sessionId !== sessionId) return;
1092
+ if (result) {
1093
+ this.applyCommands(result.commands);
1094
+ if (result.commands.length === 0) {
1095
+ if (result.status === "ended" && activeStates.has(this.snapshot.state)) {
1096
+ void this.stop("user");
1097
+ } else if (result.status === "live" && this.snapshot.state === "standby") {
1098
+ void this.startPublishing();
1099
+ }
1100
+ }
1101
+ }
1102
+ if (!this.commandPollingWanted() || this.commandPollTimer) return;
1103
+ this.scheduleCommandPoll(
1104
+ this.commandPollBackoffMs || this.commandPollInterval()
1105
+ );
1106
+ }
829
1107
  /** Heartbeat kèm chỉ số thiết bị (nếu collector được cung cấp). */
830
1108
  async sendHeartbeat() {
831
1109
  let metrics = null;
@@ -912,6 +1190,7 @@ var LivestreamEngine = class {
912
1190
  telemetry: refreshed.telemetry ?? this.grant.telemetry,
913
1191
  config: refreshed.config
914
1192
  };
1193
+ this.syncCommandSubscription();
915
1194
  this.update({ config: refreshed.config });
916
1195
  this.refreshRetryAttempt = 0;
917
1196
  if (this.liveTimersStarted) {
@@ -935,6 +1214,7 @@ var LivestreamEngine = class {
935
1214
  this.update({ state, error: state === "error" ? this.snapshot.error : null });
936
1215
  if (state === "standby") this.startStandbyTimer();
937
1216
  else this.clearStandbyTimer();
1217
+ this.syncCommandPolling();
938
1218
  if (this.snapshot.sessionId) {
939
1219
  void this.sendTelemetry("state_changed", { from: previous, to: state });
940
1220
  }
@@ -1002,6 +1282,12 @@ var LivestreamEngine = class {
1002
1282
  this.pendingReplayError = error;
1003
1283
  });
1004
1284
  }
1285
+ if (online && this.room?.needsManualReconnect) {
1286
+ const s = this.snapshot.state;
1287
+ if (s === "reconnecting" || s === "error" && this.snapshot.sessionId) {
1288
+ this.scheduleRedial(0);
1289
+ }
1290
+ }
1005
1291
  });
1006
1292
  }
1007
1293
  requireRoom() {
@@ -1044,7 +1330,10 @@ var LivestreamEngine = class {
1044
1330
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
1045
1331
  this.tokenRefreshTimer = void 0;
1046
1332
  if (!this.grant || !this.snapshot.sessionId) return;
1047
- const delay = delayOverride ?? Math.max(1e3, this.grant.tokenExpiresAt - this.now() - 6e4);
1333
+ const horizon = Math.min(this.grant.tokenExpiresAt, this.grant.telemetry.expiresAt);
1334
+ const remaining = horizon - this.now();
1335
+ if (delayOverride === void 0 && remaining <= 6e4) return;
1336
+ const delay = delayOverride ?? Math.max(1e3, remaining - 6e4);
1048
1337
  this.tokenRefreshTimer = setTimeout(() => {
1049
1338
  void this.refreshCredentials().catch(() => {
1050
1339
  if (this.disposed) return;
@@ -1210,287 +1499,180 @@ var LivestreamEngine = class {
1210
1499
  }
1211
1500
  };
1212
1501
 
1213
- // src/livekit-room-adapter.ts
1214
- var LiveKitRoomAdapter = class {
1215
- constructor(bindings) {
1216
- this.bindings = bindings;
1217
- bindings.registerGlobals();
1218
- this.room = bindings.createRoom();
1219
- this.nativeListeners = [
1220
- [bindings.events.reconnecting, () => this.emit({ type: "reconnecting" })],
1221
- [bindings.events.reconnected, () => this.emit({ type: "reconnected" })],
1222
- [bindings.events.disconnected, (reason) => {
1223
- this.published = false;
1224
- this.emit({ type: "disconnected", reason: String(reason ?? "") });
1225
- }],
1226
- [bindings.events.mediaDevicesError, (error) => this.emit({ type: "media-error", error })]
1227
- ];
1228
- if (bindings.events.dataReceived) {
1229
- this.nativeListeners.push([
1230
- bindings.events.dataReceived,
1231
- (payload) => {
1232
- try {
1233
- const text = new TextDecoder().decode(payload);
1234
- const parsed = JSON.parse(text);
1235
- if (parsed?.type === "cmd" && typeof parsed.action === "string") {
1236
- this.emit({ type: "command", action: parsed.action });
1237
- }
1238
- } catch {
1239
- }
1240
- }
1241
- ]);
1242
- }
1243
- for (const [event, listener] of this.nativeListeners) {
1244
- this.room.on(event, listener);
1245
- }
1246
- }
1247
- room;
1248
- listeners = /* @__PURE__ */ new Set();
1249
- tracks = [];
1250
- videoTrack;
1251
- audioTrack;
1252
- preview;
1253
- // Khớp engine (isCameraFront: false) — xem ghi chú ở engine.ts.
1254
- isFront = false;
1255
- orientationOverride = null;
1256
- deviceOverride = null;
1257
- published = false;
1258
- disposed = false;
1259
- quality = 720;
1260
- nativeListeners;
1261
- async preparePreview(quality) {
1262
- this.ensureNotDisposed();
1263
- this.quality = quality;
1264
- await this.ensurePreparedTracks();
1265
- this.ensureNotDisposed();
1266
- return this.preview;
1267
- }
1268
- async connect(input) {
1269
- this.ensureNotDisposed();
1270
- if (!/^wss?:\/\//i.test(input.serverUrl)) {
1271
- throw new Error(
1272
- `Mode LiveKit c\u1EA7n endpoint wss:// nh\u01B0ng phi\xEAn tr\u1EA3 v\u1EC1 "${input.serverUrl}". App tr\xEAn server \u0111ang \u0111\u1EB7t deliveryMode kh\xE1c \u2014 \u0111\u1ED5i mode trong app cho kh\u1EDBp.`
1273
- );
1274
- }
1275
- if (!this.preview) throw new Error("Preview must be prepared before connecting");
1276
- if (this.room.state !== "disconnected") await this.disconnect(false);
1277
- await this.ensurePreparedTracks();
1278
- if (!this.videoTrack || !this.audioTrack || !this.preview) {
1279
- throw new Error("Camera and microphone tracks are unavailable");
1280
- }
1281
- await this.bindings.startAudioSession();
1282
- await this.room.connect(input.serverUrl, input.participantToken, {
1283
- autoSubscribe: false
1284
- });
1285
- this.published = false;
1286
- if (input.microphoneEnabled) await this.audioTrack.unmute?.();
1287
- else await this.audioTrack.mute?.();
1288
- if (input.publish !== false) {
1289
- await this.setPublishing(true);
1290
- }
1291
- }
1292
- /** Bật/tắt publish track khi đã trong phòng — standby ↔ live. */
1293
- async setPublishing(enabled) {
1294
- this.ensureNotDisposed();
1295
- if (enabled === this.published) return;
1296
- if (!this.videoTrack || !this.audioTrack || !this.preview) {
1297
- throw new Error("Camera and microphone tracks are unavailable");
1298
- }
1299
- if (enabled) {
1300
- const publication = await this.room.localParticipant.publishTrack(
1301
- this.videoTrack,
1302
- this.videoPublishOptions()
1303
- );
1304
- await this.room.localParticipant.publishTrack(this.audioTrack);
1305
- this.preview.trackRef = {
1306
- participant: this.room.localParticipant,
1307
- publication,
1308
- source: this.bindings.cameraSource
1309
- };
1310
- this.published = true;
1311
- return;
1312
- }
1313
- await this.room.localParticipant.unpublishTrack?.(this.videoTrack, false);
1314
- await this.room.localParticipant.unpublishTrack?.(this.audioTrack, false);
1315
- delete this.preview.trackRef;
1316
- this.published = false;
1317
- }
1318
- async disconnect(stopTracks = true) {
1319
- this.ensureNotDisposed();
1320
- await this.room.disconnect(stopTracks);
1321
- this.published = false;
1322
- if (this.preview) delete this.preview.trackRef;
1323
- await this.bindings.stopAudioSession();
1324
- if (stopTracks) this.releaseTracks(true);
1325
- }
1326
- captureResolution() {
1327
- const [long, short] = this.quality === 1080 ? [1920, 1080] : [1280, 720];
1328
- return { width: long, height: short, frameRate: 30 };
1329
- }
1330
- /**
1331
- * Tùy chọn publish video. Mặc định của livekit-client (VP8, không encoding,
1332
- * không degradation preference) khiến uplink yếu bóp bitrate xuống ~600kbps
1333
- * mà VẪN giữ 1080 @ ~10fps → khối bể nát, tệ hơn 720p sạch.
1334
- *
1335
- * - videoCodec H.264: HW-encode trên Android, hiệu quả hơn VP8 ở bitrate thấp,
1336
- * trùng preset egress (H264_*) nên bỏ được 1 lần transcode phía egress.
1337
- * - simulcast tắt: viewer chỉ xem HLS do egress xuất; egress subscribe đúng
1338
- * 1 lớp đã publish, nên simulcast chỉ thêm tải/bất ổn encoder (H.264 simulcast
1339
- * kém tin cậy trên Android) mà không lợi gì cho người xem ở kiến trúc này.
1340
- * - maintain-framerate: pickleball chuyển động nhanh; khi uplink không gánh nổi
1341
- * mức đích thì HẠ ĐỘ PHÂN GIẢI (giữ nét-trên-điểm-ảnh) thay vì đóng băng fps.
1342
- * - maxBitrate: trần theo mức chất lượng; BWE vẫn tự hạ dưới trần khi mạng yếu.
1343
- */
1344
- videoPublishOptions() {
1345
- return {
1346
- source: this.bindings.cameraSource,
1347
- simulcast: false,
1348
- videoCodec: "h264",
1349
- degradationPreference: "maintain-framerate",
1350
- videoEncoding: {
1351
- maxBitrate: this.quality === 1080 ? 45e5 : 25e5,
1352
- maxFramerate: 30
1353
- }
1354
- };
1355
- }
1356
- async setCaptureOrientation(orientation) {
1357
- this.ensureNotDisposed();
1358
- this.orientationOverride = orientation;
1359
- }
1360
- /**
1361
- * livekit-client restart() chỉ giữ deviceId/facingMode khi gọi getUserMedia
1362
- * (vứt width/height/frameRate) nên track mới rơi về format mặc định
1363
- * 640x480. Native applyConstraints thì ngược lại: chỉ xử lý
1364
- * width/height/frameRate. Sau mỗi restartTrack phải nắn lại format trên
1365
- * track mới bằng applyConstraints.
1366
- */
1367
- async reapplyCaptureFormat() {
1368
- const nativeTrack = this.videoTrack?.mediaStreamTrack;
1369
- if (nativeTrack?.applyConstraints) {
1370
- await nativeTrack.applyConstraints(this.captureResolution());
1371
- }
1372
- }
1373
- async switchCamera() {
1374
- this.ensureNotDisposed();
1375
- const facingMode = this.isFront ? "environment" : "user";
1376
- const nativeTrack = this.videoTrack?.mediaStreamTrack;
1377
- if (typeof this.videoTrack?.restartTrack === "function") {
1378
- await this.videoTrack.restartTrack({ facingMode });
1379
- await this.reapplyCaptureFormat();
1380
- } else if (typeof nativeTrack?._switchCamera === "function") {
1381
- nativeTrack._switchCamera();
1382
- } else if (nativeTrack?.applyConstraints) {
1383
- await nativeTrack.applyConstraints({ facingMode });
1384
- } else {
1385
- throw new Error("Camera switching is unavailable");
1386
- }
1387
- this.deviceOverride = null;
1388
- this.isFront = !this.isFront;
1389
- }
1390
- async listCameras() {
1391
- this.ensureNotDisposed();
1392
- return await this.bindings.listCameras?.() ?? [];
1393
- }
1394
- async selectCamera(deviceId, facing) {
1395
- this.ensureNotDisposed();
1396
- if (typeof this.videoTrack?.restartTrack !== "function") {
1397
- throw new Error("Camera selection is unavailable");
1398
- }
1399
- await this.videoTrack.restartTrack({ deviceId });
1400
- await this.reapplyCaptureFormat();
1401
- this.deviceOverride = deviceId;
1402
- if (facing !== "unknown") this.isFront = facing === "front";
1403
- }
1404
- async setMicrophoneEnabled(enabled) {
1405
- this.ensureNotDisposed();
1406
- if (!this.audioTrack) return;
1407
- if (enabled) await this.audioTrack.unmute?.();
1408
- else await this.audioTrack.mute?.();
1409
- }
1410
- subscribe(listener) {
1411
- this.ensureNotDisposed();
1412
- this.listeners.add(listener);
1413
- return () => this.listeners.delete(listener);
1502
+ // src/uplink-check.ts
1503
+ var VIDEO_BITRATE_BPS = Object.freeze({
1504
+ 720: 35e5,
1505
+ 1080: 6e6
1506
+ });
1507
+ var AUDIO_BITRATE_BPS = 128e3;
1508
+ var DEFAULT_UPLOAD_URL = "https://speed.cloudflare.com/__up";
1509
+ var WARMUP_BYTES = 512 * 1024;
1510
+ function requiredMbpsFor(settings) {
1511
+ return (VIDEO_BITRATE_BPS[settings.resolution] + AUDIO_BITRATE_BPS) / 1e6;
1512
+ }
1513
+ function verdictFor(uploadMbps, requiredMbps) {
1514
+ if (uploadMbps >= requiredMbps * 1.8) return "good";
1515
+ if (uploadMbps >= requiredMbps * 1.25) return "ok";
1516
+ return "low";
1517
+ }
1518
+ function chunk(bytes) {
1519
+ const buffer = new Uint8Array(bytes);
1520
+ for (let i = 0; i < bytes; i += 977) buffer[i] = i & 255;
1521
+ return buffer;
1522
+ }
1523
+ function resolveFetch(explicit) {
1524
+ if (explicit) return explicit;
1525
+ try {
1526
+ const expoFetch = __require("expo/fetch")?.fetch;
1527
+ if (typeof expoFetch === "function") return expoFetch;
1528
+ } catch {
1414
1529
  }
1415
- async dispose() {
1416
- if (this.disposed) return;
1417
- this.disposed = true;
1418
- for (const [event, listener] of this.nativeListeners) {
1419
- this.room.off(event, listener);
1530
+ return globalThis.fetch;
1531
+ }
1532
+ async function runUplinkCheck(options) {
1533
+ const fetchImplementation = resolveFetch(options.fetch);
1534
+ const uploadUrl = options.uploadUrl ?? DEFAULT_UPLOAD_URL;
1535
+ const measureMs = options.measureMs ?? 6e3;
1536
+ const totalTimeoutMs = options.totalTimeoutMs ?? 25e3;
1537
+ const chunkBytes = options.chunkBytes ?? 2 * 1024 * 1024;
1538
+ async function post(body2, timeoutMs) {
1539
+ const abort = new AbortController();
1540
+ const timer = setTimeout(() => abort.abort(), timeoutMs);
1541
+ try {
1542
+ const response = await fetchImplementation(uploadUrl, {
1543
+ method: "POST",
1544
+ body: body2,
1545
+ signal: abort.signal
1546
+ });
1547
+ await response.text().catch(() => void 0);
1548
+ } finally {
1549
+ clearTimeout(timer);
1420
1550
  }
1421
- await this.room.disconnect(true);
1422
- await this.bindings.stopAudioSession();
1423
- this.releaseTracks(true);
1424
- this.listeners.clear();
1425
1551
  }
1426
- releaseTracks(clearPreview) {
1427
- for (const track of this.tracks) track.stop();
1428
- this.tracks = [];
1429
- this.videoTrack = void 0;
1430
- this.audioTrack = void 0;
1431
- if (clearPreview) this.preview = void 0;
1432
- else if (this.preview) delete this.preview.trackRef;
1433
- }
1434
- async ensurePreparedTracks() {
1435
- this.ensureNotDisposed();
1436
- const tracksReady = this.videoTrack?.mediaStreamTrack?.readyState !== "ended" && this.audioTrack?.mediaStreamTrack?.readyState !== "ended" && Boolean(this.videoTrack && this.audioTrack);
1437
- if (tracksReady) return;
1438
- if (this.tracks.length) this.releaseTracks(false);
1439
- const resolution = this.captureResolution();
1440
- const tracks = await this.bindings.createLocalTracks({
1441
- audio: true,
1442
- video: {
1443
- ...this.deviceOverride ? { deviceId: this.deviceOverride } : { facingMode: this.isFront ? "user" : "environment" },
1444
- resolution
1552
+ async function measurePing(url) {
1553
+ let best = null;
1554
+ for (let i = 0; i < 3; i += 1) {
1555
+ const abort = new AbortController();
1556
+ const timer = setTimeout(() => abort.abort(), 4e3);
1557
+ const started2 = Date.now();
1558
+ try {
1559
+ await fetchImplementation(url, { method: "HEAD", signal: abort.signal });
1560
+ const elapsed = Date.now() - started2;
1561
+ if (best === null || elapsed < best) best = elapsed;
1562
+ } catch {
1563
+ } finally {
1564
+ clearTimeout(timer);
1445
1565
  }
1446
- });
1447
- if (this.disposed) {
1448
- for (const track of tracks) track.stop();
1449
- throw new Error("LiveKit room adapter has been disposed");
1450
- }
1451
- this.tracks = tracks;
1452
- this.videoTrack = this.tracks.find((track) => track.kind === "video");
1453
- this.audioTrack = this.tracks.find((track) => track.kind === "audio");
1454
- if (!this.videoTrack || !this.audioTrack) {
1455
- this.releaseTracks(true);
1456
- throw new Error("LiveKit did not create camera and microphone tracks");
1457
- }
1458
- if (this.preview) {
1459
- this.preview.videoTrack = this.videoTrack;
1460
- delete this.preview.trackRef;
1461
- } else {
1462
- this.preview = { videoTrack: this.videoTrack };
1463
1566
  }
1567
+ return best;
1464
1568
  }
1465
- emit(event) {
1466
- for (const listener of this.listeners) listener(event);
1467
- }
1468
- ensureNotDisposed() {
1469
- if (this.disposed) {
1470
- throw new Error("LiveKit room adapter has been disposed");
1569
+ const deadline = Date.now() + totalTimeoutMs;
1570
+ const requiredMbps = requiredMbpsFor(options);
1571
+ const isAbort = (error) => error?.name === "AbortError";
1572
+ const warmupBytes = Math.min(WARMUP_BYTES, chunkBytes);
1573
+ const warmupStarted = Date.now();
1574
+ try {
1575
+ await post(chunk(warmupBytes), options.warmupTimeoutMs ?? 1e4);
1576
+ } catch (error) {
1577
+ if (!isAbort(error)) throw error;
1578
+ const seconds = Math.max(1, (Date.now() - warmupStarted) / 1e3);
1579
+ const uploadMbps2 = warmupBytes * 8 / seconds / 1e6;
1580
+ return { uploadMbps: uploadMbps2, pingMs: null, requiredMbps, verdict: "low" };
1581
+ }
1582
+ const body = chunk(chunkBytes);
1583
+ let sentBytes = 0;
1584
+ const started = Date.now();
1585
+ while (Date.now() - started < measureMs && Date.now() < deadline) {
1586
+ try {
1587
+ await post(body, Math.max(3e3, deadline - Date.now()));
1588
+ } catch (error) {
1589
+ if (!isAbort(error)) throw error;
1590
+ break;
1471
1591
  }
1592
+ sentBytes += body.byteLength;
1472
1593
  }
1473
- };
1594
+ const elapsedSeconds = Math.max((Date.now() - started) / 1e3, 1e-3);
1595
+ const uploadMbps = sentBytes * 8 / elapsedSeconds / 1e6;
1596
+ return {
1597
+ uploadMbps,
1598
+ pingMs: options.pingUrl ? await measurePing(options.pingUrl) : null,
1599
+ requiredMbps,
1600
+ verdict: verdictFor(uploadMbps, requiredMbps)
1601
+ };
1602
+ }
1474
1603
 
1475
1604
  // src/rtmp-room-adapter.ts
1476
- var VIDEO_BITRATE = {
1477
- 720: 35e5,
1478
- 1080: 6e6
1479
- };
1605
+ var VIDEO_BITRATE = VIDEO_BITRATE_BPS;
1606
+ var BITRATE_FLOOR = 8e5;
1607
+ var DEGRADE_FACTOR = 0.7;
1608
+ var RECOVER_FACTOR = 1.15;
1609
+ var RECOVER_AFTER_SUFFICIENT = 5;
1610
+ function nextBitrateState(state, event) {
1611
+ if (!event.sufficient) {
1612
+ return {
1613
+ ...state,
1614
+ current: Math.max(BITRATE_FLOOR, Math.round(state.current * DEGRADE_FACTOR)),
1615
+ sufficientStreak: 0
1616
+ };
1617
+ }
1618
+ const streak = state.sufficientStreak + 1;
1619
+ if (streak < RECOVER_AFTER_SUFFICIENT || state.current >= state.ceiling) {
1620
+ return { ...state, sufficientStreak: Math.min(streak, RECOVER_AFTER_SUFFICIENT) };
1621
+ }
1622
+ return {
1623
+ ...state,
1624
+ current: Math.min(state.ceiling, Math.round(state.current * RECOVER_FACTOR)),
1625
+ sufficientStreak: 0
1626
+ };
1627
+ }
1480
1628
  var RtmpRoomAdapter = class {
1481
- constructor(bindings) {
1629
+ constructor(bindings, options = {}) {
1482
1630
  this.bindings = bindings;
1631
+ this.options = options;
1632
+ const request = bindings.requestPermissions?.bind(bindings);
1633
+ if (request) this.requestPermissions = request;
1483
1634
  }
1635
+ /**
1636
+ * RTMP không tự hồi: native chỉ phát hiện đứt (watchdog iOS / onDisconnect
1637
+ * Android) và báo "disconnected" — không tự quay số lại. Engine thấy cờ này
1638
+ * là chạy vòng redial backoff. Thiếu cờ: phiên ngồi chờ "reconnected" không
1639
+ * bao giờ tới rồi chết network_timeout sau 30s (prod 17/08/2026).
1640
+ */
1641
+ needsManualReconnect = true;
1484
1642
  listeners = /* @__PURE__ */ new Set();
1485
1643
  nativeSubscription;
1644
+ bandwidthSubscription;
1486
1645
  quality = 1080;
1487
1646
  connected = false;
1647
+ prepared = false;
1648
+ /** Khung người dùng chọn trên UI (9:16/16:9) — ưu tiên hơn hướng cầm máy. */
1649
+ captureOrientation;
1650
+ bitrateState;
1651
+ /**
1652
+ * Chỉ tồn tại khi bindings hỗ trợ — engine kiểm tra truthiness để quyết định
1653
+ * fallback về runtime.requestPermissions() (getUserMedia), nên KHÔNG được
1654
+ * khai báo method luôn-có rồi tự fallback bên trong.
1655
+ */
1656
+ requestPermissions;
1657
+ /**
1658
+ * Phần tuỳ chọn của prepare(). Giữ dạng spread có điều kiện: truyền
1659
+ * `frameRate: undefined` xuống Expo Record là ghi đè mặc định của native
1660
+ * bằng null chứ không phải "bỏ qua trường".
1661
+ */
1662
+ prepareOverrides() {
1663
+ return {
1664
+ ...this.options.frameRate !== void 0 ? { frameRate: this.options.frameRate } : {},
1665
+ ...this.options.cameraId !== void 0 ? { cameraId: this.options.cameraId } : {}
1666
+ };
1667
+ }
1488
1668
  async preparePreview(quality) {
1489
1669
  this.quality = quality;
1490
1670
  await this.bindings.prepare({
1491
1671
  quality,
1492
- landscape: this.bindings.isLandscape?.() ?? true
1672
+ landscape: this.captureOrientation ? this.captureOrientation === "landscape" : this.bindings.isLandscape?.() ?? true,
1673
+ ...this.prepareOverrides()
1493
1674
  });
1675
+ this.prepared = true;
1494
1676
  return { kind: "rtmp" };
1495
1677
  }
1496
1678
  async connect(input) {
@@ -1508,6 +1690,11 @@ var RtmpRoomAdapter = class {
1508
1690
  publish: input.publish !== false
1509
1691
  });
1510
1692
  this.connected = true;
1693
+ this.bitrateState = {
1694
+ current: VIDEO_BITRATE[this.quality],
1695
+ ceiling: VIDEO_BITRATE[this.quality],
1696
+ sufficientStreak: 0
1697
+ };
1511
1698
  }
1512
1699
  async setPublishing(enabled) {
1513
1700
  await this.bindings.setPublishing(enabled);
@@ -1515,6 +1702,7 @@ var RtmpRoomAdapter = class {
1515
1702
  async disconnect(_stopTracks) {
1516
1703
  if (!this.connected) return;
1517
1704
  this.connected = false;
1705
+ this.bitrateState = void 0;
1518
1706
  await this.bindings.disconnect();
1519
1707
  }
1520
1708
  async switchCamera() {
@@ -1538,7 +1726,15 @@ var RtmpRoomAdapter = class {
1538
1726
  async setVideoBitrate(bitsPerSecond) {
1539
1727
  await this.bindings.setVideoBitrate(bitsPerSecond);
1540
1728
  }
1541
- async setCaptureOrientation(_orientation) {
1729
+ async setCaptureOrientation(orientation) {
1730
+ this.captureOrientation = orientation;
1731
+ if (this.prepared && !this.connected) {
1732
+ await this.bindings.prepare({
1733
+ quality: this.quality,
1734
+ landscape: orientation === "landscape",
1735
+ ...this.prepareOverrides()
1736
+ });
1737
+ }
1542
1738
  }
1543
1739
  subscribe(listener) {
1544
1740
  this.listeners.add(listener);
@@ -1548,6 +1744,11 @@ var RtmpRoomAdapter = class {
1548
1744
  if (mapped) this.emit(mapped);
1549
1745
  });
1550
1746
  }
1747
+ if (!this.bandwidthSubscription && this.bindings.addBandwidthListener) {
1748
+ this.bandwidthSubscription = this.bindings.addBandwidthListener(
1749
+ (event) => this.handleBandwidth(event)
1750
+ );
1751
+ }
1551
1752
  return () => {
1552
1753
  this.listeners.delete(listener);
1553
1754
  };
@@ -1555,10 +1756,22 @@ var RtmpRoomAdapter = class {
1555
1756
  async dispose() {
1556
1757
  this.nativeSubscription?.remove();
1557
1758
  this.nativeSubscription = void 0;
1759
+ this.bandwidthSubscription?.remove();
1760
+ this.bandwidthSubscription = void 0;
1558
1761
  this.listeners.clear();
1559
1762
  this.connected = false;
1763
+ this.bitrateState = void 0;
1560
1764
  await this.bindings.dispose();
1561
1765
  }
1766
+ handleBandwidth(event) {
1767
+ const state = this.bitrateState;
1768
+ if (!this.connected || !state) return;
1769
+ const next = nextBitrateState(state, event);
1770
+ this.bitrateState = next;
1771
+ if (next.current !== state.current) {
1772
+ void this.bindings.setVideoBitrate(next.current).catch(() => void 0);
1773
+ }
1774
+ }
1562
1775
  emit(event) {
1563
1776
  for (const listener of this.listeners) listener(event);
1564
1777
  }
@@ -1580,74 +1793,55 @@ function mapStatus(event) {
1580
1793
  }
1581
1794
 
1582
1795
  // src/native-runtime.ts
1583
- var globalsRegistered = false;
1584
- function createNativeLiveKitBindings() {
1585
- const reactNativeLiveKit = __require("@livekit/react-native");
1586
- const liveKit = __require("livekit-client");
1587
- return {
1588
- registerGlobals() {
1589
- if (!globalsRegistered) {
1590
- reactNativeLiveKit.registerGlobals();
1591
- globalsRegistered = true;
1592
- }
1593
- },
1594
- createRoom: () => new liveKit.Room({ adaptiveStream: false, dynacast: false }),
1595
- createLocalTracks: liveKit.createLocalTracks,
1596
- startAudioSession: () => reactNativeLiveKit.AudioSession.startAudioSession(),
1597
- stopAudioSession: () => reactNativeLiveKit.AudioSession.stopAudioSession(),
1598
- isLandscape() {
1599
- const { width, height } = __require("react-native").Dimensions.get("window");
1600
- return width > height;
1601
- },
1602
- async listCameras() {
1603
- const { mediaDevices } = __require("@livekit/react-native-webrtc");
1604
- const devices = await mediaDevices.enumerateDevices();
1605
- return devices.filter((device) => device.kind === "videoinput" && device.deviceId).map((device) => ({
1606
- deviceId: String(device.deviceId),
1607
- label: String(device.label ?? ""),
1608
- facing: device.facing === "front" || device.facing === "environment" ? device.facing : "unknown"
1609
- }));
1610
- },
1611
- cameraSource: liveKit.Track.Source.Camera,
1612
- events: {
1613
- reconnecting: liveKit.RoomEvent.Reconnecting,
1614
- reconnected: liveKit.RoomEvent.Reconnected,
1615
- disconnected: liveKit.RoomEvent.Disconnected,
1616
- mediaDevicesError: liveKit.RoomEvent.MediaDevicesError,
1617
- dataReceived: liveKit.RoomEvent.DataReceived
1618
- }
1619
- };
1620
- }
1621
- function createDefaultRuntime() {
1622
- const { NativeModules, Platform } = __require("react-native");
1796
+ function createRtmpRuntime() {
1797
+ const { Platform, PermissionsAndroid } = __require("react-native");
1623
1798
  return {
1624
1799
  assertSupported() {
1625
- if (Platform.OS !== "ios" && Platform.OS !== "android" || !NativeModules.LivekitReactNativeModule) {
1800
+ if (Platform.OS !== "ios" && Platform.OS !== "android") {
1626
1801
  throw new UnsupportedRuntimeError();
1627
1802
  }
1803
+ try {
1804
+ __require("@pickleball/rtmp-native");
1805
+ } catch {
1806
+ throw new UnsupportedRuntimeError(
1807
+ "@pickleball/rtmp-native is required: install it and rebuild the Development Build"
1808
+ );
1809
+ }
1628
1810
  },
1629
1811
  async requestPermissions() {
1812
+ if (Platform.OS === "android") {
1813
+ try {
1814
+ const granted = await PermissionsAndroid.requestMultiple([
1815
+ PermissionsAndroid.PERMISSIONS.CAMERA,
1816
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO
1817
+ ]);
1818
+ return granted[PermissionsAndroid.PERMISSIONS.CAMERA] === PermissionsAndroid.RESULTS.GRANTED && granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] === PermissionsAndroid.RESULTS.GRANTED;
1819
+ } catch {
1820
+ return false;
1821
+ }
1822
+ }
1630
1823
  try {
1631
- const { mediaDevices } = __require("@livekit/react-native-webrtc");
1632
- const stream = await mediaDevices.getUserMedia({ audio: true, video: true });
1633
- for (const track of stream.getTracks()) track.stop();
1634
- return true;
1824
+ const native = __require("@pickleball/rtmp-native").default;
1825
+ if (typeof native?.requestPermissions === "function") {
1826
+ return await native.requestPermissions() !== false;
1827
+ }
1635
1828
  } catch {
1636
- return false;
1637
1829
  }
1830
+ return true;
1638
1831
  },
1639
- activateKeepAwake() {
1640
- return __require("expo-keep-awake").activateKeepAwakeAsync(
1641
- "@pickleball/expo-sdk"
1642
- );
1643
- },
1644
- deactivateKeepAwake() {
1645
- return __require("expo-keep-awake").deactivateKeepAwake(
1646
- "@pickleball/expo-sdk"
1647
- );
1832
+ activateKeepAwake: () => Promise.resolve(
1833
+ __require("expo-keep-awake").activateKeepAwakeAsync("@pickleball/expo-sdk")
1834
+ ).catch(() => void 0),
1835
+ // deactivate trả promise và REJECT khi activity đã chết (sau crash/reload)
1836
+ // không catch là thêm một "Uncaught (in promise)" gây nhiễu log.
1837
+ deactivateKeepAwake: () => {
1838
+ void Promise.resolve(
1839
+ __require("expo-keep-awake").deactivateKeepAwake("@pickleball/expo-sdk")
1840
+ ).catch(() => void 0);
1648
1841
  }
1649
1842
  };
1650
1843
  }
1844
+ var createDefaultRuntime = createRtmpRuntime;
1651
1845
  function createDefaultStorage() {
1652
1846
  let installed = false;
1653
1847
  const install = () => {
@@ -1724,12 +1918,90 @@ function createDefaultTelemetry(fetchImplementation = globalThis.fetch, timeoutM
1724
1918
  };
1725
1919
  }
1726
1920
  function createDefaultRoomFactory() {
1727
- return () => new LiveKitRoomAdapter(createNativeLiveKitBindings());
1921
+ return createRtmpRoomFactory();
1922
+ }
1923
+ function createDefaultCommandChannel(fetchImplementation = globalThis.fetch, timeoutMs = 1e4) {
1924
+ const controllers = /* @__PURE__ */ new Set();
1925
+ return {
1926
+ async poll(credentials, after) {
1927
+ const endpoint = credentials.endpoint.replace(/\/telemetry$/, "/commands");
1928
+ const controller = new AbortController();
1929
+ controllers.add(controller);
1930
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1931
+ try {
1932
+ const response = await fetchImplementation(endpoint, {
1933
+ method: "POST",
1934
+ headers: {
1935
+ "Content-Type": "application/json",
1936
+ Authorization: `Bearer ${credentials.token}`
1937
+ },
1938
+ body: JSON.stringify({ after }),
1939
+ signal: controller.signal,
1940
+ redirect: "error"
1941
+ });
1942
+ if (!response.ok) {
1943
+ if (typeof response.text === "function") await response.text();
1944
+ throw Object.assign(new Error("Command poll rejected"), {
1945
+ status: response.status
1946
+ });
1947
+ }
1948
+ const envelope = await response.json();
1949
+ return parseCommandPollResult(envelope?.data);
1950
+ } finally {
1951
+ clearTimeout(timeout);
1952
+ controllers.delete(controller);
1953
+ }
1954
+ },
1955
+ dispose() {
1956
+ for (const controller of controllers) controller.abort();
1957
+ controllers.clear();
1958
+ }
1959
+ };
1960
+ }
1961
+ function parseCommandPollResult(value) {
1962
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1963
+ throw new Error("Command poll returned an invalid payload");
1964
+ }
1965
+ const record = value;
1966
+ const status = record.status;
1967
+ if (status !== "scheduled" && status !== "live" && status !== "ended") {
1968
+ throw new Error("Command poll returned an invalid status");
1969
+ }
1970
+ const commands = [];
1971
+ if (Array.isArray(record.commands)) {
1972
+ for (const item of record.commands) {
1973
+ if (item === null || typeof item !== "object") continue;
1974
+ const command = item;
1975
+ if (typeof command.id !== "string" || command.action !== "start" && command.action !== "stop" && command.action !== "end" || typeof command.issuedAt !== "number" || !Number.isFinite(command.issuedAt)) {
1976
+ continue;
1977
+ }
1978
+ commands.push({
1979
+ id: command.id,
1980
+ action: command.action,
1981
+ issuedAt: command.issuedAt
1982
+ });
1983
+ }
1984
+ }
1985
+ return { commands, status };
1728
1986
  }
1729
1987
  function createNativeRtmpBindings() {
1730
1988
  const native = __require("@pickleball/rtmp-native").default;
1989
+ const { Platform, PermissionsAndroid } = __require("react-native");
1731
1990
  return {
1732
1991
  prepare: (options) => native.prepare(options),
1992
+ // Android: xin quyền THUẦN, không mở camera như getUserMedia của WebRTC.
1993
+ // Máy MediaTek (Xiaomi tanzanite) chỉ cho một camera mở cùng lúc — camera
1994
+ // WebRTC chưa nhả (~400ms) là HaishinKit dính "Too many users (-87)" và
1995
+ // preview đen. iOS không có giới hạn đó nên để engine fallback getUserMedia.
1996
+ ...Platform.OS === "android" ? {
1997
+ async requestPermissions() {
1998
+ const granted = await PermissionsAndroid.requestMultiple([
1999
+ PermissionsAndroid.PERMISSIONS.CAMERA,
2000
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO
2001
+ ]);
2002
+ return granted[PermissionsAndroid.PERMISSIONS.CAMERA] === PermissionsAndroid.RESULTS.GRANTED && granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] === PermissionsAndroid.RESULTS.GRANTED;
2003
+ }
2004
+ } : {},
1733
2005
  isLandscape() {
1734
2006
  const { width, height } = __require("react-native").Dimensions.get("window");
1735
2007
  return width > height;
@@ -1743,11 +2015,12 @@ function createNativeRtmpBindings() {
1743
2015
  setMicrophoneEnabled: (enabled) => native.setMicrophoneEnabled(enabled),
1744
2016
  setVideoBitrate: (bps) => native.setVideoBitrate(bps),
1745
2017
  dispose: () => native.dispose(),
1746
- addStatusListener: (listener) => native.addListener("onStatusChanged", listener)
2018
+ addStatusListener: (listener) => native.addListener("onStatusChanged", listener),
2019
+ addBandwidthListener: (listener) => native.addListener("onBandwidth", listener)
1747
2020
  };
1748
2021
  }
1749
- function createRtmpRoomFactory() {
1750
- return () => new RtmpRoomAdapter(createNativeRtmpBindings());
2022
+ function createRtmpRoomFactory(options) {
2023
+ return () => new RtmpRoomAdapter(createNativeRtmpBindings(), options);
1751
2024
  }
1752
2025
  function createDefaultDeviceMetrics() {
1753
2026
  return async () => {
@@ -1789,6 +2062,16 @@ function createDefaultDeviceMetrics() {
1789
2062
  }
1790
2063
  } catch {
1791
2064
  }
2065
+ try {
2066
+ const native = __require("@pickleball/rtmp-native").default;
2067
+ const health = await native.getDeviceHealth();
2068
+ if (typeof health?.thermal === "number") metrics.thermal = health.thermal;
2069
+ if (typeof health?.batteryTempC === "number") {
2070
+ metrics.batteryTempC = Math.round(health.batteryTempC * 10) / 10;
2071
+ }
2072
+ } catch (error) {
2073
+ notes.push(`device health: ${String(error)}`);
2074
+ }
1792
2075
  try {
1793
2076
  const NetInfo = __require("@react-native-community/netinfo").default;
1794
2077
  const status = await NetInfo.fetch();
@@ -1797,6 +2080,19 @@ function createDefaultDeviceMetrics() {
1797
2080
  status.details?.cellularGeneration ?? "cellular"
1798
2081
  ).toUpperCase() : status.type;
1799
2082
  }
2083
+ const details = status?.details;
2084
+ if (typeof details?.ipAddress === "string" && details.ipAddress) {
2085
+ metrics.localIp = details.ipAddress;
2086
+ }
2087
+ if (typeof details?.strength === "number" && Number.isFinite(details.strength)) {
2088
+ metrics.signalPct = Math.round(details.strength);
2089
+ }
2090
+ if (typeof details?.linkSpeed === "number" && Number.isFinite(details.linkSpeed)) {
2091
+ metrics.linkMbps = Math.round(details.linkSpeed);
2092
+ }
2093
+ if (typeof details?.frequency === "number" && Number.isFinite(details.frequency) && details.frequency > 0) {
2094
+ metrics.freqMhz = Math.round(details.frequency);
2095
+ }
1800
2096
  } catch {
1801
2097
  }
1802
2098
  if (notes.length > 0) console.log("[pickleball] metrics notes:", notes.join(" | "));
@@ -1805,7 +2101,7 @@ function createDefaultDeviceMetrics() {
1805
2101
  }
1806
2102
 
1807
2103
  // src/provider.tsx
1808
- import React, { useEffect, useRef, useSyncExternalStore } from "react";
2104
+ import React, { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
1809
2105
  import { AppState } from "react-native";
1810
2106
  import { jsx } from "react/jsx-runtime";
1811
2107
  var defaultLivestreamTheme = {
@@ -1836,6 +2132,24 @@ function useLivestreamHost() {
1836
2132
  var LivestreamContext = React.createContext(null);
1837
2133
  function PickleballLiveProvider(props) {
1838
2134
  const engineRef = useRef(null);
2135
+ const keepSessionRef = useRef(props.keepSessionOnRestart);
2136
+ keepSessionRef.current = props.keepSessionOnRestart;
2137
+ const resolvedKeepRef = useRef(typeof props.keepSessionOnRestart === "function");
2138
+ useEffect(() => {
2139
+ const value2 = props.keepSessionOnRestart;
2140
+ if (typeof value2 !== "function") {
2141
+ resolvedKeepRef.current = value2 === true;
2142
+ return;
2143
+ }
2144
+ resolvedKeepRef.current = true;
2145
+ let cancelled = false;
2146
+ void Promise.resolve().then(() => value2()).then((keep) => {
2147
+ if (!cancelled) resolvedKeepRef.current = keep === true;
2148
+ }).catch(() => void 0);
2149
+ return () => {
2150
+ cancelled = true;
2151
+ };
2152
+ }, [props.keepSessionOnRestart]);
1839
2153
  const hostRef = useRef(null);
1840
2154
  const effectGenerationRef = useRef(0);
1841
2155
  if (!engineRef.current) {
@@ -1847,8 +2161,17 @@ function PickleballLiveProvider(props) {
1847
2161
  storage: props.storage ?? createDefaultStorage(),
1848
2162
  network: props.network ?? createDefaultNetwork(),
1849
2163
  telemetry: props.telemetry ?? createDefaultTelemetry(),
2164
+ commandChannel: props.commandChannel ?? createDefaultCommandChannel(),
2165
+ ...props.commandSubscription ? { commandSubscription: props.commandSubscription } : {},
1850
2166
  deviceMetrics,
1851
- roomFactory: props.roomFactory ?? createDefaultRoomFactory()
2167
+ roomFactory: props.roomFactory ?? createDefaultRoomFactory(),
2168
+ // Đọc qua ref để engine luôn thấy giá trị MỚI NHẤT: engine chỉ dựng một lần
2169
+ // (engineRef), còn prop thì đổi theo việc máy được gán/gỡ sân. Prop dạng
2170
+ // hàm được gọi xuyên qua (engine tự await) — đường cho app đọc thẳng đĩa.
2171
+ keepSessionOnRestart: () => {
2172
+ const value2 = keepSessionRef.current;
2173
+ return typeof value2 === "function" ? value2() : value2 === true;
2174
+ }
1852
2175
  };
1853
2176
  engineRef.current = new LivestreamEngine(dependencies);
1854
2177
  hostRef.current = {
@@ -1875,7 +2198,7 @@ function PickleballLiveProvider(props) {
1875
2198
  subscription.remove();
1876
2199
  queueMicrotask(() => {
1877
2200
  if (effectGenerationRef.current === effectGeneration) {
1878
- void engine.dispose();
2201
+ void engine.dispose({ keepSession: resolvedKeepRef.current });
1879
2202
  }
1880
2203
  });
1881
2204
  };
@@ -1883,21 +2206,30 @@ function PickleballLiveProvider(props) {
1883
2206
  useEffect(() => {
1884
2207
  if (snapshot.error) props.onError?.(snapshot.error);
1885
2208
  }, [props.onError, snapshot.error]);
1886
- const value = {
1887
- ...snapshot,
1888
- locale: props.locale ?? "vi",
1889
- theme: { ...defaultLivestreamTheme, ...props.theme },
1890
- prepare: () => engine.prepare(),
1891
- start: (input, options) => engine.start(input, options),
1892
- startPublishing: () => engine.startPublishing(),
1893
- stopPublishing: () => engine.stopPublishing(),
1894
- stop: (reason) => engine.stop(reason),
1895
- retry: () => engine.retry(),
1896
- switchCamera: () => engine.switchCamera(),
1897
- setMicrophoneEnabled: (enabled) => engine.setMicrophoneEnabled(enabled),
1898
- setCaptureOrientation: (orientation) => engine.setCaptureOrientation(orientation),
1899
- selectCamera: (deviceId) => engine.selectCamera(deviceId)
1900
- };
2209
+ const actions = useMemo(
2210
+ () => ({
2211
+ prepare: () => engine.prepare(),
2212
+ start: (input, options) => engine.start(input, options),
2213
+ startPublishing: () => engine.startPublishing(),
2214
+ stopPublishing: () => engine.stopPublishing(),
2215
+ stop: (reason) => engine.stop(reason),
2216
+ retry: () => engine.retry(),
2217
+ switchCamera: () => engine.switchCamera(),
2218
+ setMicrophoneEnabled: (enabled) => engine.setMicrophoneEnabled(enabled),
2219
+ setCaptureOrientation: (orientation) => engine.setCaptureOrientation(orientation),
2220
+ selectCamera: (deviceId) => engine.selectCamera(deviceId)
2221
+ }),
2222
+ [engine]
2223
+ );
2224
+ const locale = props.locale ?? "vi";
2225
+ const theme = useMemo(
2226
+ () => ({ ...defaultLivestreamTheme, ...props.theme }),
2227
+ [props.theme]
2228
+ );
2229
+ const value = useMemo(
2230
+ () => ({ ...snapshot, locale, theme, ...actions }),
2231
+ [snapshot, locale, theme, actions]
2232
+ );
1901
2233
  return /* @__PURE__ */ jsx(LivestreamHostContext.Provider, { value: hostRef.current, children: /* @__PURE__ */ jsx(LivestreamContext.Provider, { value, children: props.children }) });
1902
2234
  }
1903
2235
  function useLivestream() {
@@ -1935,9 +2267,14 @@ export {
1935
2267
  PENDING_END_KEY,
1936
2268
  LEGAL_LIVESTREAM_TRANSITIONS,
1937
2269
  LivestreamEngine,
1938
- LiveKitRoomAdapter,
2270
+ VIDEO_BITRATE_BPS,
2271
+ AUDIO_BITRATE_BPS,
2272
+ DEFAULT_UPLOAD_URL,
2273
+ requiredMbpsFor,
2274
+ verdictFor,
2275
+ runUplinkCheck,
1939
2276
  RtmpRoomAdapter,
1940
- createNativeLiveKitBindings,
2277
+ createRtmpRuntime,
1941
2278
  createDefaultRuntime,
1942
2279
  createDefaultStorage,
1943
2280
  createDefaultNetwork,