@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.
- package/CHANGELOG.md +41 -0
- package/README.md +155 -101
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +14 -2
- package/dist/advanced.d.cts +2 -2
- package/dist/advanced.d.ts +2 -2
- package/dist/advanced.js +17 -5
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-62YV3PCD.cjs +139 -0
- package/dist/chunk-6F3HN6US.js +50 -0
- package/dist/chunk-6YE5BL3A.cjs +50 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
- package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
- package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
- package/dist/chunk-ZSKSXOCZ.js +139 -0
- package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
- package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
- package/dist/device-agent/index.cjs +973 -44
- package/dist/device-agent/index.d.cts +339 -3
- package/dist/device-agent/index.d.ts +339 -3
- package/dist/device-agent/index.js +940 -11
- package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
- package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
- package/dist/index.cjs +419 -937
- package/dist/index.d.cts +315 -32
- package/dist/index.d.ts +315 -32
- package/dist/index.js +423 -941
- package/dist/plugin.cjs +11 -13
- package/dist/plugin.d.cts +15 -1
- package/dist/plugin.d.ts +15 -1
- package/dist/plugin.js +7 -9
- package/dist/realtime/index.cjs +31 -0
- package/dist/realtime/index.d.cts +15 -0
- package/dist/realtime/index.d.ts +15 -0
- package/dist/realtime/index.js +31 -0
- package/dist/ui/index.cjs +1297 -0
- package/dist/ui/index.d.cts +493 -0
- package/dist/ui/index.d.ts +493 -0
- package/dist/ui/index.js +1297 -0
- package/dist/version.cjs +2 -2
- package/dist/version.js +1 -1
- package/package.json +31 -15
- package/src/advanced.ts +11 -9
- package/src/auto-resume.tsx +259 -0
- package/src/camera-controls.ts +99 -0
- package/src/camera-grant-provider.ts +392 -0
- package/src/camera-settings.ts +133 -0
- package/src/contracts.ts +73 -0
- package/src/device-agent/agent.ts +262 -9
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +11 -0
- package/src/device-agent/convex-beat-transport.ts +140 -0
- package/src/device-agent/convex-command-subscription.ts +101 -0
- package/src/device-agent/convex-control-channel.ts +7 -1
- package/src/device-agent/http-transport.ts +22 -1
- package/src/device-agent/index.ts +22 -0
- package/src/device-agent/mqtt-channel.ts +379 -0
- package/src/device-agent/mqtt-codec.ts +347 -0
- package/src/device-agent/tournament.ts +352 -0
- package/src/engine.ts +580 -15
- package/src/http-session-provider.ts +21 -189
- package/src/index.ts +84 -5
- package/src/local-video-quality.ts +43 -0
- package/src/native-runtime.ts +234 -78
- package/src/plugin.ts +19 -6
- package/src/provider.tsx +94 -18
- package/src/realtime/index.ts +56 -0
- package/src/resume-policy.ts +85 -0
- package/src/resume.ts +132 -0
- package/src/rtmp-room-adapter.ts +164 -12
- package/src/session-grant.ts +256 -0
- package/src/ui/camera-screen.tsx +484 -0
- package/src/ui/controls-sheet.tsx +445 -0
- package/src/ui/copy.ts +326 -0
- package/src/ui/helpers.ts +105 -0
- package/src/ui/hooks.ts +59 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/preview-view.tsx +39 -0
- package/src/ui/primitives.tsx +304 -0
- package/src/uplink-check.ts +173 -0
- package/dist/advanced-BXDrDl1h.d.ts +0 -316
- package/dist/advanced-BdAa2Erv.d.cts +0 -316
- package/src/livekit-room-adapter.ts +0 -390
- package/src/livestream-screen.tsx +0 -1002
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 {
|
|
@@ -40,6 +42,20 @@ export type RoomEvent =
|
|
|
40
42
|
| { type: "command"; action: string };
|
|
41
43
|
|
|
42
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;
|
|
43
59
|
/** Bật/tắt publish track khi đã ở trong phòng (standby ↔ live). */
|
|
44
60
|
setPublishing?(enabled: boolean): Promise<void>;
|
|
45
61
|
setCaptureOrientation?(orientation: CaptureOrientation): Promise<void>;
|
|
@@ -75,6 +91,23 @@ export interface SdkNetwork {
|
|
|
75
91
|
subscribe(listener: (online: boolean) => void): () => void;
|
|
76
92
|
}
|
|
77
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
|
+
|
|
78
111
|
export interface TelemetrySink {
|
|
79
112
|
send(
|
|
80
113
|
credentials: SdkTelemetryCredentials,
|
|
@@ -83,14 +116,56 @@ export interface TelemetrySink {
|
|
|
83
116
|
dispose?(): void;
|
|
84
117
|
}
|
|
85
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
|
+
|
|
86
153
|
export interface EngineDependencies {
|
|
87
154
|
sessionProvider: LivestreamSessionProvider;
|
|
88
155
|
bootstrapInput: BootstrapInput;
|
|
89
156
|
roomFactory: () => RoomAdapter;
|
|
157
|
+
keepSessionOnRestart?: KeepSessionOnRestart;
|
|
90
158
|
runtime: SdkRuntime;
|
|
91
159
|
storage: SdkStorage;
|
|
92
160
|
network: SdkNetwork;
|
|
93
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;
|
|
94
169
|
/** Thu chỉ số thiết bị; gọi mỗi nhịp heartbeat khi đang phát. */
|
|
95
170
|
deviceMetrics?: () => Promise<DeviceMetricsSample | null>;
|
|
96
171
|
now?: () => number;
|
|
@@ -114,6 +189,16 @@ export interface DeviceMetricsSample {
|
|
|
114
189
|
storageFreeGb?: number;
|
|
115
190
|
netType?: string;
|
|
116
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;
|
|
117
202
|
rssi?: number;
|
|
118
203
|
connQuality?: string;
|
|
119
204
|
charging?: boolean;
|
|
@@ -137,11 +222,38 @@ export interface LivestreamSnapshot {
|
|
|
137
222
|
selectedCameraId: string | null;
|
|
138
223
|
elapsedMs: number;
|
|
139
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;
|
|
140
233
|
}
|
|
141
234
|
|
|
142
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";
|
|
143
243
|
/** Standby không phát quá lâu → tự kết thúc (config.standbyTimeoutMs ghi đè). */
|
|
144
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;
|
|
145
257
|
const activeStates = new Set<LivestreamState>([
|
|
146
258
|
"standby",
|
|
147
259
|
"live",
|
|
@@ -211,10 +323,23 @@ export class LivestreamEngine {
|
|
|
211
323
|
private backgroundTimer?: ReturnType<typeof setTimeout>;
|
|
212
324
|
private standbyTimer?: ReturnType<typeof setTimeout>;
|
|
213
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;
|
|
214
329
|
private heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
215
330
|
private elapsedTimer?: ReturnType<typeof setInterval>;
|
|
216
331
|
private tokenRefreshTimer?: ReturnType<typeof setTimeout>;
|
|
217
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;
|
|
218
343
|
private grant?: SdkSessionGrant;
|
|
219
344
|
private startedAt?: number;
|
|
220
345
|
/** Đang publish track (live) hay chỉ ngồi trong phòng (standby). */
|
|
@@ -252,6 +377,7 @@ export class LivestreamEngine {
|
|
|
252
377
|
selectedCameraId: null,
|
|
253
378
|
elapsedMs: 0,
|
|
254
379
|
isOnline: dependencies.network.isOnline(),
|
|
380
|
+
adoptableSessionId: null,
|
|
255
381
|
};
|
|
256
382
|
}
|
|
257
383
|
|
|
@@ -334,12 +460,28 @@ export class LivestreamEngine {
|
|
|
334
460
|
throw new UnsupportedSdkError();
|
|
335
461
|
}
|
|
336
462
|
this.update({ config: bootstrap.config });
|
|
337
|
-
|
|
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();
|
|
338
471
|
this.ensureOperation(generation);
|
|
339
472
|
if (!permitted) {
|
|
340
473
|
throw new PermissionDeniedError();
|
|
341
474
|
}
|
|
342
|
-
|
|
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
|
+
}
|
|
343
485
|
if (this.snapshot.captureOrientation) {
|
|
344
486
|
await room.setCaptureOrientation?.(this.snapshot.captureOrientation);
|
|
345
487
|
}
|
|
@@ -410,7 +552,7 @@ export class LivestreamEngine {
|
|
|
410
552
|
this.transition("connecting");
|
|
411
553
|
this.ensureBackgroundGraceTimer();
|
|
412
554
|
try {
|
|
413
|
-
await this.ensurePendingEndResolved();
|
|
555
|
+
await this.ensurePendingEndResolved(input.externalSessionId);
|
|
414
556
|
this.ensureOperation(generation);
|
|
415
557
|
} catch (error) {
|
|
416
558
|
if (
|
|
@@ -439,7 +581,13 @@ export class LivestreamEngine {
|
|
|
439
581
|
);
|
|
440
582
|
}
|
|
441
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
|
+
}
|
|
442
588
|
this.grant = grant;
|
|
589
|
+
this.commandCursor = 0;
|
|
590
|
+
this.commandPollBackoffMs = 0;
|
|
443
591
|
this.liveTimersStarted = false;
|
|
444
592
|
this.keepAwakeActive = false;
|
|
445
593
|
// Ghi sớm ý định phát để retry/reconnect khôi phục đúng trạng thái.
|
|
@@ -542,7 +690,23 @@ export class LivestreamEngine {
|
|
|
542
690
|
if (!sessionId || !this.grant) throw new Error("Missing session grant");
|
|
543
691
|
const activeGrant = this.grant;
|
|
544
692
|
try {
|
|
545
|
-
|
|
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
|
+
}
|
|
546
710
|
this.ensureOperation(generation);
|
|
547
711
|
await this.requireRoom().connect({
|
|
548
712
|
serverUrl: this.grant.serverUrl,
|
|
@@ -561,6 +725,34 @@ export class LivestreamEngine {
|
|
|
561
725
|
await this.abandonGrant(activeGrant);
|
|
562
726
|
return;
|
|
563
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
|
+
}
|
|
564
756
|
this.fail(error);
|
|
565
757
|
}
|
|
566
758
|
}
|
|
@@ -609,6 +801,10 @@ export class LivestreamEngine {
|
|
|
609
801
|
if (!this.isOperationCurrent(generation)) return;
|
|
610
802
|
}
|
|
611
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);
|
|
612
808
|
}
|
|
613
809
|
|
|
614
810
|
async setMicrophoneEnabled(enabled: boolean) {
|
|
@@ -621,11 +817,13 @@ export class LivestreamEngine {
|
|
|
621
817
|
handleRoomEvent(event: RoomEvent) {
|
|
622
818
|
if (this.disposed) return;
|
|
623
819
|
// Lệnh từ dashboard (cùng giao thức với camera box): "start" bắt đầu
|
|
624
|
-
// phát, "stop" tạm dừng phát về standby — KHÔNG kết thúc phiên.
|
|
625
|
-
//
|
|
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.
|
|
626
823
|
if (event.type === "command") {
|
|
627
824
|
if (event.action === "start") void this.startPublishing();
|
|
628
825
|
else if (event.action === "stop") void this.stopPublishing();
|
|
826
|
+
else if (event.action === "end") void this.stop("user");
|
|
629
827
|
return;
|
|
630
828
|
}
|
|
631
829
|
if (
|
|
@@ -634,20 +832,65 @@ export class LivestreamEngine {
|
|
|
634
832
|
) {
|
|
635
833
|
this.transition("reconnecting");
|
|
636
834
|
void this.sendTelemetry("reconnect", { phase: "started" });
|
|
637
|
-
|
|
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;
|
|
638
856
|
this.clearReconnectTimer();
|
|
639
857
|
this.reconnectTimer = setTimeout(() => {
|
|
640
858
|
void this.stop("network_timeout");
|
|
641
|
-
},
|
|
859
|
+
}, configured);
|
|
642
860
|
return;
|
|
643
861
|
}
|
|
644
862
|
if (event.type === "reconnected" && this.snapshot.state === "reconnecting") {
|
|
645
863
|
this.clearReconnectTimer();
|
|
864
|
+
this.stopRedialing();
|
|
646
865
|
this.transition(this.publishing ? "live" : "standby");
|
|
647
866
|
void this.sendTelemetry("reconnect", { phase: "recovered" });
|
|
648
867
|
return;
|
|
649
868
|
}
|
|
650
|
-
if (event.type === "media-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
|
+
}
|
|
651
894
|
if (
|
|
652
895
|
event.type === "disconnected" &&
|
|
653
896
|
activeStates.has(this.snapshot.state)
|
|
@@ -686,12 +929,21 @@ export class LivestreamEngine {
|
|
|
686
929
|
}, grace);
|
|
687
930
|
}
|
|
688
931
|
|
|
689
|
-
|
|
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> {
|
|
690
942
|
if (this.disposePromise) return this.disposePromise;
|
|
691
943
|
if (this.disposed) return Promise.resolve();
|
|
692
944
|
this.disposed = true;
|
|
693
945
|
this.operationGeneration += 1;
|
|
694
|
-
const sessionId = this.ownedSessionId;
|
|
946
|
+
const sessionId = options?.keepSession === true ? undefined : this.ownedSessionId;
|
|
695
947
|
let releaseEnd: () => void = () => undefined;
|
|
696
948
|
const endBarrier = new Promise<void>((resolve) => {
|
|
697
949
|
releaseEnd = resolve;
|
|
@@ -702,6 +954,7 @@ export class LivestreamEngine {
|
|
|
702
954
|
const roomCleanup = this.releaseRoom(termination?.markerReady);
|
|
703
955
|
this.clearTimers();
|
|
704
956
|
this.dependencies.telemetry?.dispose?.();
|
|
957
|
+
this.dependencies.commandChannel?.dispose?.();
|
|
705
958
|
this.pendingCriticalTelemetry = undefined;
|
|
706
959
|
this.networkUnsubscribe();
|
|
707
960
|
this.listeners.clear();
|
|
@@ -741,10 +994,52 @@ export class LivestreamEngine {
|
|
|
741
994
|
this.pendingReplayError = undefined;
|
|
742
995
|
}
|
|
743
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
|
+
|
|
744
1017
|
private async recoverStartupSession() {
|
|
745
1018
|
let pending = await this.readPendingEnd();
|
|
746
1019
|
if (!pending || this.ownsInMemorySession()) return;
|
|
747
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
|
+
}
|
|
748
1043
|
pending = { sessionId: pending.sessionId, pendingEnd: "error" };
|
|
749
1044
|
await this.dependencies.storage.setItem(
|
|
750
1045
|
PENDING_END_KEY,
|
|
@@ -771,10 +1066,30 @@ export class LivestreamEngine {
|
|
|
771
1066
|
this.pendingReplayError = undefined;
|
|
772
1067
|
}
|
|
773
1068
|
|
|
774
|
-
|
|
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) {
|
|
775
1078
|
await this.initialize();
|
|
776
1079
|
const pending = await this.readPendingEnd();
|
|
777
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
|
+
}
|
|
778
1093
|
if (!this.dependencies.network.isOnline()) {
|
|
779
1094
|
throw new NetworkUnavailableError(
|
|
780
1095
|
"Connect to the network to finish the previous livestream",
|
|
@@ -878,6 +1193,8 @@ export class LivestreamEngine {
|
|
|
878
1193
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
879
1194
|
if (this.maxSessionTimer) clearTimeout(this.maxSessionTimer);
|
|
880
1195
|
this.clearReconnectTimer();
|
|
1196
|
+
this.stopCommandPolling();
|
|
1197
|
+
this.stopCommandSubscription();
|
|
881
1198
|
this.backgroundTimer = undefined;
|
|
882
1199
|
this.heartbeatTimer = undefined;
|
|
883
1200
|
this.elapsedTimer = undefined;
|
|
@@ -887,11 +1204,236 @@ export class LivestreamEngine {
|
|
|
887
1204
|
this.liveTimersStarted = false;
|
|
888
1205
|
}
|
|
889
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
|
+
|
|
890
1261
|
private clearReconnectTimer() {
|
|
891
1262
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
892
1263
|
this.reconnectTimer = undefined;
|
|
893
1264
|
}
|
|
894
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
|
+
|
|
895
1437
|
/** Heartbeat kèm chỉ số thiết bị (nếu collector được cung cấp). */
|
|
896
1438
|
private async sendHeartbeat() {
|
|
897
1439
|
let metrics: DeviceMetricsSample | null = null;
|
|
@@ -992,6 +1534,9 @@ export class LivestreamEngine {
|
|
|
992
1534
|
telemetry: refreshed.telemetry ?? this.grant.telemetry,
|
|
993
1535
|
config: refreshed.config,
|
|
994
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();
|
|
995
1540
|
this.update({ config: refreshed.config });
|
|
996
1541
|
this.refreshRetryAttempt = 0;
|
|
997
1542
|
if (this.liveTimersStarted) {
|
|
@@ -1019,6 +1564,7 @@ export class LivestreamEngine {
|
|
|
1019
1564
|
// minutes LiveKit + pin (timer reset mỗi lần rời/vào lại standby).
|
|
1020
1565
|
if (state === "standby") this.startStandbyTimer();
|
|
1021
1566
|
else this.clearStandbyTimer();
|
|
1567
|
+
this.syncCommandPolling();
|
|
1022
1568
|
if (this.snapshot.sessionId) {
|
|
1023
1569
|
void this.sendTelemetry("state_changed", { from: previous, to: state });
|
|
1024
1570
|
}
|
|
@@ -1099,6 +1645,19 @@ export class LivestreamEngine {
|
|
|
1099
1645
|
this.pendingReplayError = error;
|
|
1100
1646
|
});
|
|
1101
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
|
+
}
|
|
1102
1661
|
});
|
|
1103
1662
|
}
|
|
1104
1663
|
|
|
@@ -1148,9 +1707,15 @@ export class LivestreamEngine {
|
|
|
1148
1707
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
1149
1708
|
this.tokenRefreshTimer = undefined;
|
|
1150
1709
|
if (!this.grant || !this.snapshot.sessionId) return;
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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 mà 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);
|
|
1154
1719
|
this.tokenRefreshTimer = setTimeout(() => {
|
|
1155
1720
|
void this.refreshCredentials().catch(() => {
|
|
1156
1721
|
if (this.disposed) return;
|