@pickleball/expo-sdk 0.1.1 → 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 (117) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +163 -40
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +47 -0
  6. package/dist/advanced.d.cts +3 -0
  7. package/dist/advanced.d.ts +3 -0
  8. package/dist/advanced.js +47 -0
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-2JHULJZ7.js +265 -0
  12. package/dist/chunk-62YV3PCD.cjs +139 -0
  13. package/dist/chunk-6F3HN6US.js +50 -0
  14. package/dist/chunk-6YE5BL3A.cjs +50 -0
  15. package/dist/chunk-7OX2DSKI.cjs +33 -0
  16. package/dist/chunk-7Z7TN5N3.js +318 -0
  17. package/dist/chunk-DHDX3ZOO.js +800 -0
  18. package/dist/chunk-GXQYMPHE.cjs +9 -0
  19. package/dist/chunk-IUIRVKJI.cjs +2290 -0
  20. package/dist/chunk-KFQGP6VL.js +33 -0
  21. package/dist/chunk-LCWGNGEN.cjs +265 -0
  22. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  23. package/dist/chunk-XGP2MN5R.js +2290 -0
  24. package/dist/chunk-XL4CISVQ.js +9 -0
  25. package/dist/chunk-YGSQ25UA.cjs +800 -0
  26. package/dist/chunk-ZSKSXOCZ.js +139 -0
  27. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  28. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  29. package/dist/device-agent/index.cjs +1427 -0
  30. package/dist/device-agent/index.d.cts +493 -0
  31. package/dist/device-agent/index.d.ts +493 -0
  32. package/dist/device-agent/index.js +1427 -0
  33. package/dist/engine-BSvSdCSE.d.cts +741 -0
  34. package/dist/engine-BSvSdCSE.d.ts +741 -0
  35. package/dist/index.cjs +426 -6174
  36. package/dist/index.d.cts +299 -1400
  37. package/dist/index.d.ts +299 -1400
  38. package/dist/index.js +413 -6063
  39. package/dist/match/index.cjs +2456 -0
  40. package/dist/match/index.d.cts +253 -0
  41. package/dist/match/index.d.ts +253 -0
  42. package/dist/match/index.js +2456 -0
  43. package/dist/match/remote/index.cjs +21 -0
  44. package/dist/match/remote/index.d.cts +67 -0
  45. package/dist/match/remote/index.d.ts +67 -0
  46. package/dist/match/remote/index.js +21 -0
  47. package/dist/match/voice/index.cjs +23 -0
  48. package/dist/match/voice/index.d.cts +114 -0
  49. package/dist/match/voice/index.d.ts +114 -0
  50. package/dist/match/voice/index.js +23 -0
  51. package/dist/plugin.cjs +52 -71
  52. package/dist/plugin.d.cts +28 -1
  53. package/dist/plugin.d.ts +28 -1
  54. package/dist/plugin.js +26 -23
  55. package/dist/realtime/index.cjs +31 -0
  56. package/dist/realtime/index.d.cts +15 -0
  57. package/dist/realtime/index.d.ts +15 -0
  58. package/dist/realtime/index.js +31 -0
  59. package/dist/types-CrmcX408.d.cts +176 -0
  60. package/dist/types-CrmcX408.d.ts +176 -0
  61. package/dist/types-DiKWjwvN.d.cts +96 -0
  62. package/dist/types-DiKWjwvN.d.ts +96 -0
  63. package/dist/ui/index.cjs +1297 -0
  64. package/dist/ui/index.d.cts +493 -0
  65. package/dist/ui/index.d.ts +493 -0
  66. package/dist/ui/index.js +1297 -0
  67. package/dist/version.cjs +4 -32
  68. package/dist/version.js +2 -1
  69. package/package.json +95 -15
  70. package/src/advanced.ts +49 -0
  71. package/src/auto-resume.tsx +259 -0
  72. package/src/camera-controls.ts +99 -0
  73. package/src/camera-grant-provider.ts +392 -0
  74. package/src/camera-settings.ts +133 -0
  75. package/src/contracts.ts +73 -0
  76. package/src/device-agent/agent.ts +696 -0
  77. package/src/device-agent/beat-policy.ts +46 -0
  78. package/src/device-agent/component.tsx +60 -0
  79. package/src/device-agent/convex-beat-transport.ts +140 -0
  80. package/src/device-agent/convex-command-subscription.ts +101 -0
  81. package/src/device-agent/convex-control-channel.ts +88 -0
  82. package/src/device-agent/http-transport.ts +164 -0
  83. package/src/device-agent/index.ts +26 -0
  84. package/src/device-agent/mqtt-channel.ts +379 -0
  85. package/src/device-agent/mqtt-codec.ts +347 -0
  86. package/src/device-agent/tournament.ts +352 -0
  87. package/src/engine.ts +601 -20
  88. package/src/errors.ts +16 -0
  89. package/src/http-session-provider.ts +21 -183
  90. package/src/index.ts +117 -14
  91. package/src/local-video-quality.ts +43 -0
  92. package/src/match/index.ts +29 -0
  93. package/src/match/provider.tsx +4 -2
  94. package/src/match/remote/hid-gesture.ts +10 -2
  95. package/src/match/remote/types.ts +6 -4
  96. package/src/match/voice/scheduler.ts +6 -1
  97. package/src/match/voice/service.ts +16 -4
  98. package/src/native-runtime.ts +283 -77
  99. package/src/plugin.ts +54 -20
  100. package/src/provider.tsx +129 -22
  101. package/src/realtime/index.ts +56 -0
  102. package/src/resume-policy.ts +85 -0
  103. package/src/resume.ts +132 -0
  104. package/src/rtmp-room-adapter.ts +348 -0
  105. package/src/session-grant.ts +256 -0
  106. package/src/ui/camera-screen.tsx +484 -0
  107. package/src/ui/controls-sheet.tsx +445 -0
  108. package/src/ui/copy.ts +326 -0
  109. package/src/ui/helpers.ts +105 -0
  110. package/src/ui/hooks.ts +59 -0
  111. package/src/ui/index.ts +42 -0
  112. package/src/ui/preview-view.tsx +39 -0
  113. package/src/ui/primitives.tsx +304 -0
  114. package/src/uplink-check.ts +173 -0
  115. package/dist/chunk-VHLUJQG5.js +0 -17
  116. package/src/livekit-room-adapter.ts +0 -353
  117. package/src/livestream-screen.tsx +0 -980
package/src/engine.ts CHANGED
@@ -7,6 +7,8 @@ import type {
7
7
  SdkSessionGrant,
8
8
  SdkTelemetryCredentials,
9
9
  SdkTelemetryEvent,
10
+ SessionCommandPollResult,
11
+ SessionCommandRecord,
10
12
  StartLivestreamInput,
11
13
  } from "./contracts";
12
14
  import {
@@ -18,6 +20,7 @@ import {
18
20
  SdkDisabledError,
19
21
  SessionConflictError,
20
22
  UnsupportedSdkError,
23
+ isSessionGoneError,
21
24
  normalizeError,
22
25
  } from "./errors";
23
26
  import { isSdkRemoteConfig, isSdkVersionSupported } from "./contracts";
@@ -39,6 +42,20 @@ export type RoomEvent =
39
42
  | { type: "command"; action: string };
40
43
 
41
44
  export interface RoomAdapter {
45
+ /**
46
+ * Xin quyền camera/mic theo cách của mode. Không có thì engine dùng
47
+ * runtime.requestPermissions() (getUserMedia của WebRTC — mở camera thật
48
+ * ~400ms). Mode device-rtmp PHẢI tự xin bằng API quyền thuần: máy Android
49
+ * MediaTek chỉ cho một camera mở, camera WebRTC chưa nhả là HaishinKit dính
50
+ * "Too many users (-87)" và preview đen.
51
+ */
52
+ requestPermissions?(): Promise<boolean>;
53
+ /**
54
+ * true = adapter KHÔNG tự nối lại khi rớt (RTMP: native chỉ báo
55
+ * "disconnected" rồi đứng im) — engine phải chủ động quay số lại qua retry().
56
+ * LiveKit bỏ trống: SDK của nó tự hồi và tự bắn "reconnected".
57
+ */
58
+ readonly needsManualReconnect?: boolean;
42
59
  /** Bật/tắt publish track khi đã ở trong phòng (standby ↔ live). */
43
60
  setPublishing?(enabled: boolean): Promise<void>;
44
61
  setCaptureOrientation?(orientation: CaptureOrientation): Promise<void>;
@@ -74,6 +91,23 @@ export interface SdkNetwork {
74
91
  subscribe(listener: (online: boolean) => void): () => void;
75
92
  }
76
93
 
94
+ /**
95
+ * Khi app khởi động lại và thấy dấu của một phiên chưa được dừng có chủ ý, hàm
96
+ * này quyết định: GIỮ phiên đó để nhận lại, hay kết thúc nó như trước.
97
+ *
98
+ * Mặc định (không truyền) = kết thúc, giữ nguyên hành vi cho mọi SDK đối tác.
99
+ * App first-party trả true khi máy đang gán sân: lúc đó máy sụp nguồn giữa trận
100
+ * là chuyện của thiết bị gác sân, và nối lại đúng phiên cũ mới giữ được link xem
101
+ * và VOD liền mạch. Máy không gán sân thì vẫn dọn sạch như cũ.
102
+ */
103
+ /**
104
+ * Được phép async có chủ đích: câu trả lời đúng nằm TRÊN ĐĨA (sân đã gán có
105
+ * persist), còn React state lúc engine khởi động thường CHƯA restore xong.
106
+ * Bản sync-đọc-state đã gây lỗi thật (18/08/2026): recover chạy trước restore
107
+ * → keep=false oan → app tự kết án phiên đáng giữ rồi arm phiên chờ THỨ HAI.
108
+ */
109
+ export type KeepSessionOnRestart = () => boolean | Promise<boolean>;
110
+
77
111
  export interface TelemetrySink {
78
112
  send(
79
113
  credentials: SdkTelemetryCredentials,
@@ -82,14 +116,56 @@ export interface TelemetrySink {
82
116
  dispose?(): void;
83
117
  }
84
118
 
119
+ /**
120
+ * Kênh nhận lệnh điều khiển cho mode device-rtmp: RTMP không có data channel
121
+ * nên start/stop/end từ server (match-start của trọng tài, dashboard) về qua
122
+ * HTTP poll, auth bằng chính telemetry token trong grant.
123
+ */
124
+ export interface SessionCommandChannel {
125
+ poll(
126
+ credentials: SdkTelemetryCredentials,
127
+ after: number,
128
+ ): Promise<SessionCommandPollResult>;
129
+ dispose?(): void;
130
+ }
131
+
132
+ /**
133
+ * Kênh nhận lệnh device-rtmp qua WEBSOCKET (Convex subscription) — thay poll
134
+ * HTTP 2s cho máy first-party nối thẳng Convex. Lệnh về tức thì, lúc rảnh gần
135
+ * như 0 function call (poll 2s ở 200 máy là ~518M call/tháng). Có kênh này thì
136
+ * engine hạ poll về nhịp reconcile chậm làm lưới an toàn; thiếu thì poll như cũ.
137
+ */
138
+ export interface SessionCommandSubscription {
139
+ /**
140
+ * Mở subscription cho phiên; `onCommands` được gọi mỗi lần hàng lệnh đổi
141
+ * (kể cả lần đầu). `onError` báo websocket/token hỏng để engine tăng lại nhịp
142
+ * poll. Trả hàm huỷ subscription (KHÔNG đóng client — tái dùng qua phiên).
143
+ */
144
+ subscribe(
145
+ credentials: SdkTelemetryCredentials,
146
+ sessionId: string,
147
+ onCommands: (commands: SessionCommandRecord[]) => void,
148
+ onError?: (error: Error) => void,
149
+ ): () => void;
150
+ dispose?(): void;
151
+ }
152
+
85
153
  export interface EngineDependencies {
86
154
  sessionProvider: LivestreamSessionProvider;
87
155
  bootstrapInput: BootstrapInput;
88
156
  roomFactory: () => RoomAdapter;
157
+ keepSessionOnRestart?: KeepSessionOnRestart;
89
158
  runtime: SdkRuntime;
90
159
  storage: SdkStorage;
91
160
  network: SdkNetwork;
92
161
  telemetry?: TelemetrySink;
162
+ /** Kênh poll lệnh cho phiên device-rtmp; thiếu thì mode này điếc lệnh server. */
163
+ commandChannel?: SessionCommandChannel;
164
+ /**
165
+ * Kênh nhận lệnh qua websocket (first-party). Có thì lệnh về tức thì và poll
166
+ * tụt về nhịp reconcile; thiếu (máy đối tác qua proxy) thì chỉ dùng poll.
167
+ */
168
+ commandSubscription?: SessionCommandSubscription;
93
169
  /** Thu chỉ số thiết bị; gọi mỗi nhịp heartbeat khi đang phát. */
94
170
  deviceMetrics?: () => Promise<DeviceMetricsSample | null>;
95
171
  now?: () => number;
@@ -113,6 +189,16 @@ export interface DeviceMetricsSample {
113
189
  storageFreeGb?: number;
114
190
  netType?: string;
115
191
  linkMbps?: number;
192
+ /** Cường độ sóng WiFi 0-100% — CHỈ Android (iOS không mở cho app). */
193
+ signalPct?: number;
194
+ /** IP nội bộ — phân biệt máy đang bám router nào, không cần quyền vị trí. */
195
+ localIp?: string;
196
+ /**
197
+ * Tần số kênh WiFi (MHz) — CHỈ Android. 2412–2484 = băng 2.4 GHz, ≥ 5000 =
198
+ * 5 GHz. Thay cho SSID (bị khoá sau quyền vị trí): 22/08/2026 một máy rơi từ
199
+ * AP 5 GHz của giải sang WiFi quán 2.4 GHz mà heartbeat không phân biệt được.
200
+ */
201
+ freqMhz?: number;
116
202
  rssi?: number;
117
203
  connQuality?: string;
118
204
  charging?: boolean;
@@ -136,11 +222,38 @@ export interface LivestreamSnapshot {
136
222
  selectedCameraId: string | null;
137
223
  elapsedMs: number;
138
224
  isOnline: boolean;
225
+ /**
226
+ * Phiên cũ còn sống mà app vừa khởi động lại có thể NHẬN LẠI (thay vì tạo phiên
227
+ * mới). Chỉ khác null khi engine tìm thấy dấu của một phiên chưa được dừng có
228
+ * chủ ý VÀ caller nói phiên đó đáng giữ (keepSessionOnRestart). Tầng trên
229
+ * (AutoResume) đọc trường này để nối lại đúng phiên cũ — giữ nguyên link xem và
230
+ * VOD liền một mạch.
231
+ */
232
+ adoptableSessionId: string | null;
139
233
  }
140
234
 
141
235
  const PENDING_END_KEY = "@pickleball/expo-sdk/pending-end";
236
+ /**
237
+ * Khung 9:16/16:9 người dùng chọn lần cuối — dùng lại cho remote start
238
+ * (device-agent gọi prepare() khi app không ở màn chọn khung). Thiếu persist
239
+ * thì adapter RTMP đoán theo hướng UI: máy dọc mà khung ngang → ASPECT_FILL
240
+ * crop trên dưới, hình như bị zoom nặng.
241
+ */
242
+ const CAPTURE_ORIENTATION_KEY = "@pickleball/expo-sdk/capture-orientation";
142
243
  /** Standby không phát quá lâu → tự kết thúc (config.standbyTimeoutMs ghi đè). */
143
244
  const DEFAULT_STANDBY_TIMEOUT_MS = 15 * 60_000;
245
+ /** Poll lệnh device-rtmp: standby dày hơn để match-start bắt máy trong ~2s. */
246
+ /** Còn dưới mức này mới đi xin token mới trong lúc quay số lại. */
247
+ const TOKEN_REFRESH_MARGIN_MS = 2 * 60_000;
248
+ const COMMAND_POLL_STANDBY_MS = 2_000;
249
+ const COMMAND_POLL_LIVE_MS = 4_000;
250
+ const COMMAND_POLL_MAX_BACKOFF_MS = 30_000;
251
+ /**
252
+ * Nhịp poll khi ĐÃ có websocket đẩy lệnh: poll chỉ còn là lưới đối chiếu
253
+ * (bắt lệnh/status lỡ nếu ws chớp), không phải đường giao lệnh chính. 30s đủ
254
+ * dày để không kẹt quá lâu, đủ thưa để không phá mục tiêu cắt call ở 200 máy.
255
+ */
256
+ const COMMAND_POLL_RECONCILE_MS = 30_000;
144
257
  const activeStates = new Set<LivestreamState>([
145
258
  "standby",
146
259
  "live",
@@ -210,10 +323,23 @@ export class LivestreamEngine {
210
323
  private backgroundTimer?: ReturnType<typeof setTimeout>;
211
324
  private standbyTimer?: ReturnType<typeof setTimeout>;
212
325
  private reconnectTimer?: ReturnType<typeof setTimeout>;
326
+ private redialTimer?: ReturnType<typeof setTimeout>;
327
+ /** Đang trong vòng quay số lại — xem doRetry() để biết vì sao cần cờ này. */
328
+ private redialing = false;
213
329
  private heartbeatTimer?: ReturnType<typeof setInterval>;
214
330
  private elapsedTimer?: ReturnType<typeof setInterval>;
215
331
  private tokenRefreshTimer?: ReturnType<typeof setTimeout>;
216
332
  private maxSessionTimer?: ReturnType<typeof setTimeout>;
333
+ private commandPollTimer?: ReturnType<typeof setTimeout>;
334
+ /** Huỷ subscription websocket lệnh của phiên hiện tại (nếu đang mở). */
335
+ private commandUnsubscribe?: () => void;
336
+ /** Khoá phiên+token đang subscribe — đổi thì resubscribe (token refresh/đổi phiên). */
337
+ private commandSubscriptionKey?: string;
338
+ /** Websocket đã đẩy được ít nhất một lần → coi là sống, cho phép hạ nhịp poll. */
339
+ private commandSubscriptionActive = false;
340
+ /** issuedAt của lệnh cuối đã xử lý — server chỉ trả lệnh mới hơn mốc này. */
341
+ private commandCursor = 0;
342
+ private commandPollBackoffMs = 0;
217
343
  private grant?: SdkSessionGrant;
218
344
  private startedAt?: number;
219
345
  /** Đang publish track (live) hay chỉ ngồi trong phòng (standby). */
@@ -240,13 +366,18 @@ export class LivestreamEngine {
240
366
  sessionId: null,
241
367
  previewTrack: null,
242
368
  isMicrophoneEnabled: true,
243
- isCameraFront: true,
369
+ // Camera SAU mặc định: thiết bị quay sân luôn dùng camera sau, bắt người
370
+ // dùng bấm đổi mỗi buổi là thừa. Phải khớp với mặc định của MỌI adapter
371
+ // (LiveKitRoomAdapter.isFront, module RTMP cameraPosition) — lệch một chỗ
372
+ // là nhãn UI và hình thực tế ngược nhau vĩnh viễn.
373
+ isCameraFront: false,
244
374
  captureOrientation: null,
245
375
  deviceMetrics: null,
246
376
  cameras: [],
247
377
  selectedCameraId: null,
248
378
  elapsedMs: 0,
249
379
  isOnline: dependencies.network.isOnline(),
380
+ adoptableSessionId: null,
250
381
  };
251
382
  }
252
383
 
@@ -329,12 +460,28 @@ export class LivestreamEngine {
329
460
  throw new UnsupportedSdkError();
330
461
  }
331
462
  this.update({ config: bootstrap.config });
332
- const permitted = await this.dependencies.runtime.requestPermissions();
463
+ // Tạo room TRƯỚC khi xin quyền để adapter được chọn chiến lược: mode
464
+ // RTMP xin bằng API quyền thuần thay vì getUserMedia (xem RoomAdapter
465
+ // .requestPermissions). Constructor của adapter không đụng camera nên
466
+ // tạo sớm không có tác dụng phụ.
467
+ const room = this.ensureRoom();
468
+ const permitted = room.requestPermissions
469
+ ? await room.requestPermissions()
470
+ : await this.dependencies.runtime.requestPermissions();
333
471
  this.ensureOperation(generation);
334
472
  if (!permitted) {
335
473
  throw new PermissionDeniedError();
336
474
  }
337
- const room = this.ensureRoom();
475
+ if (!this.snapshot.captureOrientation) {
476
+ // Khôi phục khung đã chọn lần trước (xem CAPTURE_ORIENTATION_KEY).
477
+ const stored = await this.dependencies.storage
478
+ .getItem(CAPTURE_ORIENTATION_KEY)
479
+ .catch(() => null);
480
+ this.ensureOperation(generation);
481
+ if (stored === "portrait" || stored === "landscape") {
482
+ this.update({ captureOrientation: stored });
483
+ }
484
+ }
338
485
  if (this.snapshot.captureOrientation) {
339
486
  await room.setCaptureOrientation?.(this.snapshot.captureOrientation);
340
487
  }
@@ -405,7 +552,7 @@ export class LivestreamEngine {
405
552
  this.transition("connecting");
406
553
  this.ensureBackgroundGraceTimer();
407
554
  try {
408
- await this.ensurePendingEndResolved();
555
+ await this.ensurePendingEndResolved(input.externalSessionId);
409
556
  this.ensureOperation(generation);
410
557
  } catch (error) {
411
558
  if (
@@ -434,7 +581,13 @@ export class LivestreamEngine {
434
581
  );
435
582
  }
436
583
  this.ownedSessionId = grant.sessionId;
584
+ // Nhận lại xong thì thôi công bố: dấu đã hoàn thành vai trò chặn của nó.
585
+ if (this.snapshot.adoptableSessionId === grant.sessionId) {
586
+ this.update({ adoptableSessionId: null });
587
+ }
437
588
  this.grant = grant;
589
+ this.commandCursor = 0;
590
+ this.commandPollBackoffMs = 0;
438
591
  this.liveTimersStarted = false;
439
592
  this.keepAwakeActive = false;
440
593
  // Ghi sớm ý định phát để retry/reconnect khôi phục đúng trạng thái.
@@ -537,7 +690,23 @@ export class LivestreamEngine {
537
690
  if (!sessionId || !this.grant) throw new Error("Missing session grant");
538
691
  const activeGrant = this.grant;
539
692
  try {
540
- await this.refreshCredentials(generation);
693
+ // Chỉ làm mới thông tin phiên khi token SẮP hết hạn. Trước đây gọi vô điều
694
+ // kiện mỗi nhịp quay số, dù token còn 15 phút — đó chính là thứ làm một lần
695
+ // thử "đắt" và buộc phải giãn nhịp ra. Bỏ nó thì mỗi nhịp chỉ còn đúng một
696
+ // cú bắt tay RTMP, nên thử nhanh không còn tốn gì đáng kể.
697
+ //
698
+ // Không mất đường biết phiên đã chết: vòng poll lệnh chạy song song mỗi
699
+ // 2-4 giây (commandPollingWanted có cả trạng thái reconnecting) và trả
700
+ // status "ended" ngay khi mạng về.
701
+ // CHỈ bỏ qua trong vòng quay số tự động. retry() thủ công và mode LiveKit
702
+ // vẫn làm mới như cũ: chúng hiếm khi chạy nên không có gì để tiết kiệm, và
703
+ // lần làm mới đó còn kéo về config mới nhất.
704
+ const expiresAt = this.grant?.tokenExpiresAt ?? 0;
705
+ const tokenStillFresh =
706
+ expiresAt > 0 && expiresAt - Date.now() >= TOKEN_REFRESH_MARGIN_MS;
707
+ if (!this.redialing || !tokenStillFresh) {
708
+ await this.refreshCredentials(generation);
709
+ }
541
710
  this.ensureOperation(generation);
542
711
  await this.requireRoom().connect({
543
712
  serverUrl: this.grant.serverUrl,
@@ -556,6 +725,34 @@ export class LivestreamEngine {
556
725
  await this.abandonGrant(activeGrant);
557
726
  return;
558
727
  }
728
+ // Server nói thẳng phiên không còn: 404 (đã biến mất) hoặc 409 (đã kết
729
+ // thúc — trọng tài bấm dừng / nhả sân trong lúc máy đang mất mạng). Thử lại
730
+ // không bao giờ cứu được, nên DỪNG thay vì quay số tới lưới cuối 1 giờ.
731
+ //
732
+ // Bám MÃ HTTP chứ không bám tên lớp lỗi: engine cũng tự ném
733
+ // SessionConflictError cho 4 tình huống CỤC BỘ (prepare khi đang có phiên,
734
+ // preview chưa sẵn…) và những lỗi đó không mang httpStatus. Bắt theo tên
735
+ // lớp là sẽ dừng nhầm ở lỗi nội bộ.
736
+ const httpStatus = (error as { httpStatus?: unknown }).httpStatus;
737
+ if (httpStatus === 404 || httpStatus === 409) {
738
+ this.stopRedialing();
739
+ void this.stop("user");
740
+ return;
741
+ }
742
+ // Một nhịp quay số trượt KHÔNG phải là phiên chết. Giữ nguyên state
743
+ // "reconnecting", chỉ ghi lỗi để chẩn đoán.
744
+ //
745
+ // Vì sao quan trọng: recordError đẩy state sang "error", mà
746
+ // TournamentController coi "error" là tín hiệu để (sau grace 90s) cắt
747
+ // phiên và arm phiên mới — tức là nó sẽ giết đúng cái phiên engine đang
748
+ // cố cứu. Trước bản này trần 75 giây che mất chuyện đó vì engine luôn
749
+ // buông trước; bỏ trần mà không sửa đây thì chỉ đổi kẻ giết phiên từ
750
+ // engine sang controller. "reconnecting" không nằm trong danh sách khiến
751
+ // controller re-arm, nên giữ nguyên state là đủ, không cần cờ mới ở đó.
752
+ if (this.redialing && this.snapshot.sessionId) {
753
+ this.update({ error: normalizeError(error) });
754
+ return;
755
+ }
559
756
  this.fail(error);
560
757
  }
561
758
  }
@@ -604,6 +801,10 @@ export class LivestreamEngine {
604
801
  if (!this.isOperationCurrent(generation)) return;
605
802
  }
606
803
  this.update({ captureOrientation: orientation });
804
+ // Nhớ bền lựa chọn để remote start dùng lại (CAPTURE_ORIENTATION_KEY).
805
+ void this.dependencies.storage
806
+ .setItem(CAPTURE_ORIENTATION_KEY, orientation)
807
+ .catch(() => undefined);
607
808
  }
608
809
 
609
810
  async setMicrophoneEnabled(enabled: boolean) {
@@ -616,11 +817,13 @@ export class LivestreamEngine {
616
817
  handleRoomEvent(event: RoomEvent) {
617
818
  if (this.disposed) return;
618
819
  // Lệnh từ dashboard (cùng giao thức với camera box): "start" bắt đầu
619
- // phát, "stop" tạm dừng phát về standby — KHÔNG kết thúc phiên. Kết thúc
620
- // phiên vẫn hành động riêng (nút trên app / Kết thúc trên dashboard).
820
+ // phát, "stop" tạm dừng phát về standby — KHÔNG kết thúc phiên. "end"
821
+ // kết thúc hẳn phiên (match-end của đối tác qua REST theo sân) chế độ
822
+ // giải đấu dựa vào đây để re-arm phiên standby mới cho trận kế tiếp.
621
823
  if (event.type === "command") {
622
824
  if (event.action === "start") void this.startPublishing();
623
825
  else if (event.action === "stop") void this.stopPublishing();
826
+ else if (event.action === "end") void this.stop("user");
624
827
  return;
625
828
  }
626
829
  if (
@@ -629,20 +832,65 @@ export class LivestreamEngine {
629
832
  ) {
630
833
  this.transition("reconnecting");
631
834
  void this.sendTelemetry("reconnect", { phase: "started" });
632
- const timeout = this.snapshot.config?.reconnectTimeoutMs ?? 30_000;
835
+ if (this.room?.needsManualReconnect) {
836
+ // RTMP: KHÔNG có hạn giờ. Engine quay số tới khi nối lại được, hoặc tới
837
+ // khi biết chắc phiên đã bị kết thúc phía server (poll lệnh bên dưới),
838
+ // hoặc tới khi người ở sân bấm dừng.
839
+ //
840
+ // Trước bản này có trần 75 giây rồi tự gọi stop("network_timeout"). Đo
841
+ // thật 18/08/2026 với cú ngắt mạng 16 phút trên cả Android lẫn iOS: app
842
+ // buông ở giây 76 và tắt hẳn camera, nên mạng có về ở phút thứ 2 nó cũng
843
+ // không biết. Cái giá của việc buông KHÔNG phải "phiên chết" mà là: link
844
+ // xem đổi (người đang xem mất hẳn), VOD tách đôi, và stream key bị thu
845
+ // hồi nên chính thiết bị cũng không nối lại được phiên đó nữa.
846
+ //
847
+ // Vì sao app không có tư cách phán "phiên này chết": lúc mất mạng app
848
+ // chính là thằng mù. Chỉ server thấy được sự vắng mặt, và nó đã có lưới
849
+ // cuối riêng (RTMP_NO_PUBLISHER_GRACE_MS 1 giờ).
850
+ this.clearReconnectTimer();
851
+ this.scheduleRedial(0);
852
+ return;
853
+ }
854
+ // Mode LiveKit giữ nguyên: SDK của nó tự hồi, hết hạn thì kết thúc phiên.
855
+ const configured = this.snapshot.config?.reconnectTimeoutMs ?? 30_000;
633
856
  this.clearReconnectTimer();
634
857
  this.reconnectTimer = setTimeout(() => {
635
858
  void this.stop("network_timeout");
636
- }, timeout);
859
+ }, configured);
637
860
  return;
638
861
  }
639
862
  if (event.type === "reconnected" && this.snapshot.state === "reconnecting") {
640
863
  this.clearReconnectTimer();
864
+ this.stopRedialing();
641
865
  this.transition(this.publishing ? "live" : "standby");
642
866
  void this.sendTelemetry("reconnect", { phase: "recovered" });
643
867
  return;
644
868
  }
645
- if (event.type === "media-error") this.recordError(event.error);
869
+ if (event.type === "media-error") {
870
+ // Với adapter tự quay số (RTMP), media-error GIỮA luồng là mất kết nối chứ
871
+ // không phải án tử — xử lý y như "disconnected" thay vì ghi lỗi.
872
+ //
873
+ // Vì sao cần: RootEncoder trên Android báo broken pipe qua
874
+ // `onConnectionFailed` (RtmpSender: "send error: …"), module dịch thành
875
+ // status "error" → media-error, mà nhánh này trước đây CHỈ gọi recordError.
876
+ // Không ai mở vòng redial nên phiên nằm chết vĩnh viễn dù mạng đã về từ lâu
877
+ // — Android không phát lại được, trong khi iOS hồi được vì HaishinKit báo
878
+ // "disconnected" nên đi đúng nhánh dưới (báo cáo 18/08/2026; logcat Redmi:
879
+ // Acknowledgement tắt lúc 03:17:52 rồi không còn một lần connect nào nữa).
880
+ //
881
+ // KHÔNG được recordError trước rồi mới hồi phục: recordError chuyển state
882
+ // sang "error", mà "error" không thuộc activeStates và nhánh "reconnecting"
883
+ // chỉ nhận live/standby — ghi lỗi trước là tự khoá luôn đường hồi phục.
884
+ // Đánh đổi: mất chi tiết lỗi trong snapshot, bù lại bằng log native và
885
+ // telemetry "reconnect". Chỉ giới hạn trong needsManualReconnect: mode
886
+ // LiveKit tự hồi, kéo nó vào "reconnecting" chỉ để bật thêm doom timer là
887
+ // hại chứ không lợi.
888
+ if (this.room?.needsManualReconnect && activeStates.has(this.snapshot.state)) {
889
+ this.handleRoomEvent({ type: "reconnecting" });
890
+ return;
891
+ }
892
+ this.recordError(event.error);
893
+ }
646
894
  if (
647
895
  event.type === "disconnected" &&
648
896
  activeStates.has(this.snapshot.state)
@@ -681,12 +929,21 @@ export class LivestreamEngine {
681
929
  }, grace);
682
930
  }
683
931
 
684
- dispose(): Promise<void> {
932
+ /**
933
+ * `keepSession` = engine bị tháo nhưng phiên phải sống tiếp để lần mở app sau
934
+ * nhận lại. Mặc định false: giữ NGUYÊN hành vi cũ cho mọi SDK đối tác.
935
+ *
936
+ * Lưu ý dispose KHÔNG BAO GIỜ là đường của "người dùng bấm dừng" — nút dừng đi
937
+ * qua doStop, mà doStop xoá ownedSessionId trước, nên tới đây sessionId đã là
938
+ * undefined. Vì vậy nhánh end ở đây luôn là teardown NGOÀI Ý MUỐN (app bị giết,
939
+ * component remount), đúng chỗ cần giữ phiên.
940
+ */
941
+ dispose(options?: { keepSession?: boolean }): Promise<void> {
685
942
  if (this.disposePromise) return this.disposePromise;
686
943
  if (this.disposed) return Promise.resolve();
687
944
  this.disposed = true;
688
945
  this.operationGeneration += 1;
689
- const sessionId = this.ownedSessionId;
946
+ const sessionId = options?.keepSession === true ? undefined : this.ownedSessionId;
690
947
  let releaseEnd: () => void = () => undefined;
691
948
  const endBarrier = new Promise<void>((resolve) => {
692
949
  releaseEnd = resolve;
@@ -697,6 +954,7 @@ export class LivestreamEngine {
697
954
  const roomCleanup = this.releaseRoom(termination?.markerReady);
698
955
  this.clearTimers();
699
956
  this.dependencies.telemetry?.dispose?.();
957
+ this.dependencies.commandChannel?.dispose?.();
700
958
  this.pendingCriticalTelemetry = undefined;
701
959
  this.networkUnsubscribe();
702
960
  this.listeners.clear();
@@ -736,10 +994,52 @@ export class LivestreamEngine {
736
994
  this.pendingReplayError = undefined;
737
995
  }
738
996
 
997
+ /**
998
+ * Phiên tưởng đáng nhận lại hoá ra đã chết phía server → BỎ dấu để máy arm
999
+ * phiên mới được.
1000
+ *
1001
+ * 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
1002
+ * trò chống phiên song song của nó), mà lượt start duy nhất được cho qua lại là
1003
+ * lượt nhận lại chính cái phiên đã chết — không bao giờ thành công. Đã dính
1004
+ * thật 18/08/2026: lưới standby 30 phút kết thúc phiên trong lúc máy ngủ, mở
1005
+ * lại thì app đứng ở "error" vĩnh viễn.
1006
+ */
1007
+ async abandonAdoptableSession(sessionId: string): Promise<void> {
1008
+ if (this.snapshot.adoptableSessionId !== sessionId) return;
1009
+ try {
1010
+ await this.dependencies.storage.removeItem(PENDING_END_KEY);
1011
+ } catch {
1012
+ // Xoá dấu hỏng thì lần mở sau thử lại — không được để nó chặn việc arm mới.
1013
+ }
1014
+ this.update({ adoptableSessionId: null });
1015
+ }
1016
+
739
1017
  private async recoverStartupSession() {
740
1018
  let pending = await this.readPendingEnd();
741
1019
  if (!pending || this.ownsInMemorySession()) return;
742
1020
  if (pending.pendingEnd === null) {
1021
+ // pendingEnd === null nghĩa là phiên chưa từng được dừng CÓ CHỦ Ý — app
1022
+ // biến mất giữa chừng (sụp nguồn, bị vuốt tắt, OEM giết). Trước đây chỗ này
1023
+ // tự kết án "error" rồi gọi end(), tức chính app vừa mở lại là thứ giết
1024
+ // phiên cũ — và nó chạy TRƯỚC heartbeat, TRƯỚC AutoResume nên đường nối lại
1025
+ // không bao giờ có cơ hội.
1026
+ //
1027
+ // Nay: nếu caller nói phiên này đáng giữ (máy đang gác sân), để nguyên dấu
1028
+ // và công bố id ra snapshot cho tầng trên nhận lại. KHÔNG xoá dấu — nó vẫn
1029
+ // là thứ chặn việc tạo phiên MỚI song song, đúng vai trò gốc của nó; chỉ
1030
+ // đổi điều kiện gỡ chặn từ "phiên cũ đã kết thúc" thành "phiên cũ đã được
1031
+ // nhận lại hoặc xác nhận biến mất".
1032
+ const keep = (await this.dependencies.keepSessionOnRestart?.()) === true;
1033
+ // Mốc chẩn đoán CỐ Ý giữ lại: quyết định này xảy ra một lần lúc khởi động,
1034
+ // không tái hiện được, và đoán sai nó thì mất phiên. Bản Release nuốt
1035
+ // console.log nhưng warn vẫn tới logcat/Console.app.
1036
+ console.warn(
1037
+ `[picklive] khoi dong: phien ${pending.sessionId} chua duoc dung co chu y -> ${keep ? "GIU de nhan lai" : "ket thuc"}`,
1038
+ );
1039
+ if (keep) {
1040
+ this.update({ adoptableSessionId: pending.sessionId });
1041
+ return;
1042
+ }
743
1043
  pending = { sessionId: pending.sessionId, pendingEnd: "error" };
744
1044
  await this.dependencies.storage.setItem(
745
1045
  PENDING_END_KEY,
@@ -747,18 +1047,49 @@ export class LivestreamEngine {
747
1047
  );
748
1048
  }
749
1049
  if (!this.dependencies.network.isOnline()) return;
750
- await this.dependencies.sessionProvider.end({
751
- sessionId: pending.sessionId,
752
- reason: pending.pendingEnd ?? "error",
753
- });
1050
+ try {
1051
+ await this.dependencies.sessionProvider.end({
1052
+ sessionId: pending.sessionId,
1053
+ reason: pending.pendingEnd ?? "error",
1054
+ });
1055
+ } catch (error) {
1056
+ // CHỈ 404 mới xoá dấu: phiên đã biến mất hẳn (bị watchdog dọn, thuộc
1057
+ // deployment khác, ID sai) nên thử lại bao nhiêu lần cũng vô ích, mà giữ
1058
+ // dấu thì thiết bị bị khoá VĨNH VIỄN với thông báo "Đang có phiên phát
1059
+ // khác" — box hết pin giữa trận là dính, chỉ gỡ được bằng cài lại app.
1060
+ //
1061
+ // Mất mạng hay 5xx thì KHÔNG xoá: phiên cũ có thể vẫn đang chạy thật, xoá
1062
+ // dấu sẽ dẫn tới hai phiên live song song.
1063
+ if (!isSessionGoneError(error)) throw error;
1064
+ }
754
1065
  await this.dependencies.storage.removeItem(PENDING_END_KEY);
755
1066
  this.pendingReplayError = undefined;
756
1067
  }
757
1068
 
758
- private async ensurePendingEndResolved() {
1069
+ /**
1070
+ * Dấu phiên cũ chặn thao tác mới cho tới khi nó được giải quyết.
1071
+ *
1072
+ * `externalSessionId` = id lượt start đang xin đi qua; bỏ trống nghĩa là
1073
+ * prepare (mở camera), vốn phải luôn được phép khi đang định nhận lại — không
1074
+ * 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 ở
1075
+ * màn lỗi "The previous livestream is still pending end".
1076
+ */
1077
+ private async ensurePendingEndResolved(externalSessionId?: string) {
759
1078
  await this.initialize();
760
1079
  const pending = await this.readPendingEnd();
761
1080
  if (!pending) return;
1081
+ // Phiên đang chờ NHẬN LẠI: dấu đổi vai trò, không còn là án tử mà là chốt
1082
+ // chặn phiên song song. Cho qua đúng hai thứ — prepare, và lượt start nhận
1083
+ // lại CHÍNH phiên đó. Mọi lượt start khác vẫn bị chặn như cũ, nên bảo đảm
1084
+ // "không bao giờ có hai phiên live song song" giữ nguyên.
1085
+ if (
1086
+ pending.pendingEnd === null &&
1087
+ this.snapshot.adoptableSessionId === pending.sessionId &&
1088
+ (externalSessionId === undefined ||
1089
+ externalSessionId === `resume-${pending.sessionId}`)
1090
+ ) {
1091
+ return;
1092
+ }
762
1093
  if (!this.dependencies.network.isOnline()) {
763
1094
  throw new NetworkUnavailableError(
764
1095
  "Connect to the network to finish the previous livestream",
@@ -862,6 +1193,8 @@ export class LivestreamEngine {
862
1193
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
863
1194
  if (this.maxSessionTimer) clearTimeout(this.maxSessionTimer);
864
1195
  this.clearReconnectTimer();
1196
+ this.stopCommandPolling();
1197
+ this.stopCommandSubscription();
865
1198
  this.backgroundTimer = undefined;
866
1199
  this.heartbeatTimer = undefined;
867
1200
  this.elapsedTimer = undefined;
@@ -871,11 +1204,236 @@ export class LivestreamEngine {
871
1204
  this.liveTimersStarted = false;
872
1205
  }
873
1206
 
1207
+ /**
1208
+ * Quay số lại với backoff cho adapter cần reconnect thủ công. Mỗi nhịp gọi
1209
+ * retry() (refresh credentials + room.connect + enterSession). Thành công →
1210
+ * enterSession phát "publishing" → nhánh "reconnected" dọn cả hai timer.
1211
+ * Thất bại → doRetry() đưa state về "error" (vẫn còn sessionId) và nhịp sau
1212
+ * retry() lại kéo về "reconnecting" — lặp tới khi doom timer 30s kết liễu.
1213
+ * Backoff thưa dần để không dội server khi ingest thật sự sập.
1214
+ */
1215
+ private scheduleRedial(attempt: number) {
1216
+ this.clearRedialTimer();
1217
+ this.redialing = true;
1218
+ // Biết chắc đang mất mạng thì đừng thử: mọi lần thử đều trượt, và sự kiện
1219
+ // "mạng đã về" chắc chắn sẽ tới (ensureNetworkSubscription) rồi quay số lại
1220
+ // từ nhịp 0. Đây là thứ cho phép nhịp đầu bằng 0 mà không tạo vòng lặp rỗng.
1221
+ if (!this.dependencies.network.isOnline()) return;
1222
+ // Nhịp đầu = 0: có mạng thì nối lại NGAY, không đợi đồng hồ nào.
1223
+ //
1224
+ // Vì sao trước đây giãn đều là sai: chi phí một lần thử phụ thuộc hoàn toàn
1225
+ // vào LÝ DO thất bại. Mất mạng thật thì cả hai lệnh (Convex + bắt tay RTMP)
1226
+ // trượt tức thì, không ra khỏi máy — thử mỗi giây cũng gần như miễn phí.
1227
+ // Chỉ ca "có mạng mà bị từ chối" mới tốn thật, và đuôi thưa bên dưới lo ca đó.
1228
+ // Giãn đều là bắt ca rẻ trả giá cho ca đắt.
1229
+ const delays = [0, 1_000, 2_000, 4_000, 8_000, 15_000, 30_000];
1230
+ const delay = delays[Math.min(attempt, delays.length - 1)];
1231
+ this.redialTimer = setTimeout(() => {
1232
+ if (this.disposed) return;
1233
+ const s = this.snapshot.state;
1234
+ // Chỉ còn ý nghĩa khi vẫn đang kẹt: reconnecting, hoặc error-còn-phiên
1235
+ // (doRetry thất bại nhịp trước). Mọi trạng thái khác = đã hồi/đã kết thúc.
1236
+ if (s !== "reconnecting" && !(s === "error" && this.snapshot.sessionId)) {
1237
+ return;
1238
+ }
1239
+ void this.retry()
1240
+ .catch(() => {})
1241
+ .finally(() => {
1242
+ if (this.disposed) return;
1243
+ const after = this.snapshot.state;
1244
+ if (after === "reconnecting" || (after === "error" && this.snapshot.sessionId)) {
1245
+ this.scheduleRedial(attempt + 1);
1246
+ }
1247
+ });
1248
+ }, delay);
1249
+ }
1250
+
1251
+ private clearRedialTimer() {
1252
+ if (this.redialTimer) clearTimeout(this.redialTimer);
1253
+ this.redialTimer = undefined;
1254
+ }
1255
+
1256
+ private stopRedialing() {
1257
+ this.redialing = false;
1258
+ this.clearRedialTimer();
1259
+ }
1260
+
874
1261
  private clearReconnectTimer() {
875
1262
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
876
1263
  this.reconnectTimer = undefined;
877
1264
  }
878
1265
 
1266
+ /**
1267
+ * Poll lệnh chỉ khi phiên là device-rtmp (grant rtmp://) và đang standby/
1268
+ * live. Ngoài hai trạng thái đó không có gì để điều khiển: connecting chưa
1269
+ * có phiên chạy, reconnecting đã có reconnect timer lo, ending/ended thì thôi.
1270
+ */
1271
+ private commandPollingWanted() {
1272
+ const grant = this.grant;
1273
+ return Boolean(
1274
+ this.dependencies.commandChannel &&
1275
+ grant &&
1276
+ /^rtmps?:\/\//i.test(grant.serverUrl) &&
1277
+ // "reconnecting" PHẢI có trong danh sách này. Sau khi bỏ trần 75 giây,
1278
+ // máy có thể quay số hàng giờ; nếu trong lúc đó trọng tài kết thúc trận
1279
+ // hoặc nhả sân, đây là đường duy nhất để máy biết mà thôi quay số vào một
1280
+ // phiên đã chết (poll trả status "ended" → nhánh ở pollSessionCommands).
1281
+ // Trước đây chỉ poll ở standby/live nên máy mất mạng là mù hoàn toàn.
1282
+ activeStates.has(this.snapshot.state),
1283
+ );
1284
+ }
1285
+
1286
+ private syncCommandPolling() {
1287
+ if (this.disposed || !this.commandPollingWanted()) {
1288
+ this.stopCommandPolling();
1289
+ this.stopCommandSubscription();
1290
+ return;
1291
+ }
1292
+ this.syncCommandSubscription();
1293
+ // Poll ngay khi vào standby/live — lệnh có thể đã chờ sẵn (match-start
1294
+ // bắn "start" trong lúc app còn đang connect). Có websocket thì poll vẫn
1295
+ // chạy làm lưới, chỉ ở nhịp reconcile chậm (commandPollInterval).
1296
+ if (!this.commandPollTimer) this.scheduleCommandPoll(0);
1297
+ }
1298
+
1299
+ /**
1300
+ * Mở/đổi websocket nhận lệnh cho phiên hiện tại. Chỉ máy first-party có
1301
+ * dependency này (đối tác qua proxy thì undefined → chỉ poll). Resubscribe
1302
+ * khi đổi phiên HOẶC token (refresh) vì cả hai đi vào khoá.
1303
+ */
1304
+ private syncCommandSubscription() {
1305
+ const subscription = this.dependencies.commandSubscription;
1306
+ const grant = this.grant;
1307
+ if (!subscription || !grant) return;
1308
+ const key = `${grant.sessionId}:${grant.telemetry.token}`;
1309
+ if (this.commandSubscriptionKey === key) return;
1310
+ this.stopCommandSubscription();
1311
+ this.commandSubscriptionKey = key;
1312
+ const sessionId = grant.sessionId;
1313
+ this.commandUnsubscribe = subscription.subscribe(
1314
+ grant.telemetry,
1315
+ sessionId,
1316
+ (commands) => {
1317
+ // Phiên đã đổi trong lúc callback bay tới → bỏ (thuộc phiên cũ).
1318
+ if (this.disposed || this.grant?.sessionId !== sessionId) return;
1319
+ // Lần đẩy đầu tiên xác nhận ws sống → cho phép hạ poll về reconcile.
1320
+ this.commandSubscriptionActive = true;
1321
+ this.applyCommands(commands);
1322
+ },
1323
+ () => {
1324
+ // Websocket/token hỏng → coi như chưa có ws, poll tăng lại nhịp nhanh
1325
+ // ở lần lên lịch kế. Không đóng gì ở đây: ConvexClient tự nối lại, và
1326
+ // token refresh sẽ resubscribe qua khoá đổi.
1327
+ this.commandSubscriptionActive = false;
1328
+ },
1329
+ );
1330
+ }
1331
+
1332
+ private stopCommandSubscription() {
1333
+ if (this.commandUnsubscribe) {
1334
+ try {
1335
+ this.commandUnsubscribe();
1336
+ } catch {
1337
+ /* huỷ subscription lỗi — kệ, engine đang dọn dẹp */
1338
+ }
1339
+ }
1340
+ this.commandUnsubscribe = undefined;
1341
+ this.commandSubscriptionKey = undefined;
1342
+ this.commandSubscriptionActive = false;
1343
+ }
1344
+
1345
+ private stopCommandPolling() {
1346
+ if (this.commandPollTimer) clearTimeout(this.commandPollTimer);
1347
+ this.commandPollTimer = undefined;
1348
+ this.commandPollBackoffMs = 0;
1349
+ }
1350
+
1351
+ private scheduleCommandPoll(delayMs: number) {
1352
+ this.commandPollTimer = setTimeout(() => {
1353
+ this.commandPollTimer = undefined;
1354
+ void this.pollSessionCommands();
1355
+ }, delayMs);
1356
+ (this.commandPollTimer as unknown as { unref?: () => void }).unref?.();
1357
+ }
1358
+
1359
+ private commandPollInterval() {
1360
+ // Websocket đang đẩy lệnh → poll chỉ là lưới đối chiếu nhịp chậm. Chưa xác
1361
+ // nhận ws sống (chưa đẩy lần nào, hoặc vừa lỗi) thì giữ nhịp nhanh như cũ.
1362
+ if (this.commandSubscriptionActive) return COMMAND_POLL_RECONCILE_MS;
1363
+ return this.snapshot.state === "live"
1364
+ ? COMMAND_POLL_LIVE_MS
1365
+ : COMMAND_POLL_STANDBY_MS;
1366
+ }
1367
+
1368
+ /**
1369
+ * Áp lệnh từ poll HOẶC subscription vào cùng một state machine. Subscription
1370
+ * trả cả lệnh CŨ (after:0) nên phải lọc theo con trỏ ở đây; poll đã lọc
1371
+ * server-side (after=cursor) nên bước lọc này vô hại với nó.
1372
+ */
1373
+ private applyCommands(commands: SessionCommandRecord[]) {
1374
+ for (const command of commands) {
1375
+ if (command.issuedAt <= this.commandCursor) continue;
1376
+ this.commandCursor = command.issuedAt;
1377
+ // Cùng đường với lệnh data channel LiveKit — state machine tự nuốt lệnh
1378
+ // thừa (start khi đã live, stop khi đã standby...).
1379
+ this.handleRoomEvent({ type: "command", action: command.action });
1380
+ }
1381
+ }
1382
+
1383
+ private async pollSessionCommands() {
1384
+ if (!this.commandPollingWanted()) return;
1385
+ const channel = this.dependencies.commandChannel;
1386
+ const grant = this.grant;
1387
+ if (!channel || !grant) return;
1388
+ const sessionId = grant.sessionId;
1389
+ let result: SessionCommandPollResult | null = null;
1390
+ try {
1391
+ result = await channel.poll(grant.telemetry, this.commandCursor);
1392
+ this.commandPollBackoffMs = 0;
1393
+ } catch (error) {
1394
+ const status = (error as { status?: unknown })?.status;
1395
+ // Telemetry token hết hạn → vòng refresh cấp token mới, poll sau dùng nó.
1396
+ if (status === 401) {
1397
+ void this.refreshCredentials().catch(() => undefined);
1398
+ }
1399
+ // 409 ở kênh lệnh = grant này đã bị GRANT MỚI thay (đối tác cấp lại cho
1400
+ // máy khác) hoặc phiên đã đóng ngoài cửa sổ flush. Máy này phải DỪNG tại
1401
+ // chỗ, không redial tranh publisher với máy mới; provider của camera grant
1402
+ // coi end 409 là đã xong nên dấu pending-end được dọn sạch.
1403
+ if (status === 409 && this.grant?.sessionId === sessionId) {
1404
+ void this.stop("user");
1405
+ return;
1406
+ }
1407
+ this.commandPollBackoffMs = Math.min(
1408
+ COMMAND_POLL_MAX_BACKOFF_MS,
1409
+ Math.max(this.commandPollInterval(), this.commandPollBackoffMs * 2),
1410
+ );
1411
+ }
1412
+ // Phiên đã đổi trong lúc chờ mạng → kết quả thuộc phiên cũ, bỏ.
1413
+ if (this.disposed || this.grant?.sessionId !== sessionId) return;
1414
+ if (result) {
1415
+ this.applyCommands(result.commands);
1416
+ // Đối chiếu trạng thái — lưới an toàn khi lệnh rơi vì quá TTL: server
1417
+ // bảo "live" mà máy còn ngồi chờ thì tự phát; "ended" thì tự kết thúc.
1418
+ // CHỈ đối chiếu khi vòng này không giao lệnh nào — có lệnh thì lệnh là
1419
+ // nguồn hành động, đối chiếu thêm sẽ gọi startPublishing trùng lặp.
1420
+ if (result.commands.length === 0) {
1421
+ if (result.status === "ended" && activeStates.has(this.snapshot.state)) {
1422
+ void this.stop("user");
1423
+ } else if (
1424
+ result.status === "live" &&
1425
+ this.snapshot.state === "standby"
1426
+ ) {
1427
+ void this.startPublishing();
1428
+ }
1429
+ }
1430
+ }
1431
+ if (!this.commandPollingWanted() || this.commandPollTimer) return;
1432
+ this.scheduleCommandPoll(
1433
+ this.commandPollBackoffMs || this.commandPollInterval(),
1434
+ );
1435
+ }
1436
+
879
1437
  /** Heartbeat kèm chỉ số thiết bị (nếu collector được cung cấp). */
880
1438
  private async sendHeartbeat() {
881
1439
  let metrics: DeviceMetricsSample | null = null;
@@ -976,6 +1534,9 @@ export class LivestreamEngine {
976
1534
  telemetry: refreshed.telemetry ?? this.grant.telemetry,
977
1535
  config: refreshed.config,
978
1536
  };
1537
+ // Token có thể vừa đổi → resubscribe websocket lệnh bằng token mới
1538
+ // (subscription giữ token cũ sẽ bị server từ chối là "expired").
1539
+ this.syncCommandSubscription();
979
1540
  this.update({ config: refreshed.config });
980
1541
  this.refreshRetryAttempt = 0;
981
1542
  if (this.liveTimersStarted) {
@@ -1003,6 +1564,7 @@ export class LivestreamEngine {
1003
1564
  // minutes LiveKit + pin (timer reset mỗi lần rời/vào lại standby).
1004
1565
  if (state === "standby") this.startStandbyTimer();
1005
1566
  else this.clearStandbyTimer();
1567
+ this.syncCommandPolling();
1006
1568
  if (this.snapshot.sessionId) {
1007
1569
  void this.sendTelemetry("state_changed", { from: previous, to: state });
1008
1570
  }
@@ -1083,6 +1645,19 @@ export class LivestreamEngine {
1083
1645
  this.pendingReplayError = error;
1084
1646
  });
1085
1647
  }
1648
+ // Mạng vừa về mà đang kẹt → quay số lại NGAY từ nhịp 0.
1649
+ //
1650
+ // Đây là đường hồi phục CHÍNH, không phải đường phụ: khi mất mạng thì
1651
+ // scheduleRedial không đặt timer nào cả, nên sự kiện này là thứ duy nhất
1652
+ // khởi động lại vòng quay số. Nhịp đầu bằng 0, và nếu cú đầu trượt vì định
1653
+ // tuyến chưa sẵn (hệ điều hành báo "connected" trước khi dùng được thật)
1654
+ // thì thang 1s/2s/4s bắt ngay sau đó.
1655
+ if (online && this.room?.needsManualReconnect) {
1656
+ const s = this.snapshot.state;
1657
+ if (s === "reconnecting" || (s === "error" && this.snapshot.sessionId)) {
1658
+ this.scheduleRedial(0);
1659
+ }
1660
+ }
1086
1661
  });
1087
1662
  }
1088
1663
 
@@ -1132,9 +1707,15 @@ export class LivestreamEngine {
1132
1707
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
1133
1708
  this.tokenRefreshTimer = undefined;
1134
1709
  if (!this.grant || !this.snapshot.sessionId) return;
1135
- const delay =
1136
- delayOverride ??
1137
- Math.max(1_000, this.grant.tokenExpiresAt - this.now() - 60_000);
1710
+ // Hạn gần nhất của hai token. Mode device-rtmp: tokenExpiresAt là HORIZON
1711
+ // của phiên (stream key sống theo phiên, refresh trả lại cùng mốc) — sát
1712
+ // hạn vẫn hẹn refresh là POST /refresh mỗi giây suốt phút cuối rồi 409
1713
+ // (đo: ~60 call/phiên/máy). Còn ≤60s thì thôi: phiên sắp hết theo trần
1714
+ // maxSessionDuration, không có token nào cứu được nữa.
1715
+ const horizon = Math.min(this.grant.tokenExpiresAt, this.grant.telemetry.expiresAt);
1716
+ const remaining = horizon - this.now();
1717
+ if (delayOverride === undefined && remaining <= 60_000) return;
1718
+ const delay = delayOverride ?? Math.max(1_000, remaining - 60_000);
1138
1719
  this.tokenRefreshTimer = setTimeout(() => {
1139
1720
  void this.refreshCredentials().catch(() => {
1140
1721
  if (this.disposed) return;