@pickleball/expo-sdk 0.1.1

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 (129) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app.plugin.js +6 -0
  5. package/dist/chunk-VHLUJQG5.js +17 -0
  6. package/dist/index.cjs +6284 -0
  7. package/dist/index.d.cts +1436 -0
  8. package/dist/index.d.ts +1436 -0
  9. package/dist/index.js +6186 -0
  10. package/dist/plugin.cjs +153 -0
  11. package/dist/plugin.d.cts +21 -0
  12. package/dist/plugin.d.ts +21 -0
  13. package/dist/plugin.js +131 -0
  14. package/dist/version.cjs +35 -0
  15. package/dist/version.d.cts +4 -0
  16. package/dist/version.d.ts +4 -0
  17. package/dist/version.js +6 -0
  18. package/package.json +126 -0
  19. package/src/contracts.ts +211 -0
  20. package/src/engine.ts +1332 -0
  21. package/src/errors.ts +128 -0
  22. package/src/http-session-provider.ts +259 -0
  23. package/src/index.ts +16 -0
  24. package/src/livekit-room-adapter.ts +353 -0
  25. package/src/livestream-screen.tsx +980 -0
  26. package/src/match/contracts.ts +137 -0
  27. package/src/match/engine.ts +702 -0
  28. package/src/match/persistence/default-store.native.ts +20 -0
  29. package/src/match/persistence/default-store.ts +11 -0
  30. package/src/match/persistence/index.ts +6 -0
  31. package/src/match/persistence/memory-driver.ts +255 -0
  32. package/src/match/persistence/migrations.ts +40 -0
  33. package/src/match/persistence/sqlite-driver.ts +348 -0
  34. package/src/match/persistence/store.ts +497 -0
  35. package/src/match/persistence/types.ts +98 -0
  36. package/src/match/provider.tsx +728 -0
  37. package/src/match/remote/deduplicator.ts +54 -0
  38. package/src/match/remote/encoding.ts +12 -0
  39. package/src/match/remote/hid-gesture.ts +61 -0
  40. package/src/match/remote/index.ts +5 -0
  41. package/src/match/remote/native-service.ts +62 -0
  42. package/src/match/remote/parser.ts +134 -0
  43. package/src/match/remote/types.ts +116 -0
  44. package/src/match/replay.ts +102 -0
  45. package/src/match/state.ts +72 -0
  46. package/src/match/validation.ts +375 -0
  47. package/src/match/voice/assets/court-left.wav +0 -0
  48. package/src/match/voice/assets/court-right.wav +0 -0
  49. package/src/match/voice/assets/court-switch.wav +0 -0
  50. package/src/match/voice/assets/game-point.wav +0 -0
  51. package/src/match/voice/assets/game-won.wav +0 -0
  52. package/src/match/voice/assets/match-won.wav +0 -0
  53. package/src/match/voice/assets/number-0.wav +0 -0
  54. package/src/match/voice/assets/number-1.wav +0 -0
  55. package/src/match/voice/assets/number-10.wav +0 -0
  56. package/src/match/voice/assets/number-11.wav +0 -0
  57. package/src/match/voice/assets/number-12.wav +0 -0
  58. package/src/match/voice/assets/number-13.wav +0 -0
  59. package/src/match/voice/assets/number-14.wav +0 -0
  60. package/src/match/voice/assets/number-15.wav +0 -0
  61. package/src/match/voice/assets/number-16.wav +0 -0
  62. package/src/match/voice/assets/number-17.wav +0 -0
  63. package/src/match/voice/assets/number-18.wav +0 -0
  64. package/src/match/voice/assets/number-19.wav +0 -0
  65. package/src/match/voice/assets/number-2.wav +0 -0
  66. package/src/match/voice/assets/number-20.wav +0 -0
  67. package/src/match/voice/assets/number-3.wav +0 -0
  68. package/src/match/voice/assets/number-30.wav +0 -0
  69. package/src/match/voice/assets/number-4.wav +0 -0
  70. package/src/match/voice/assets/number-40.wav +0 -0
  71. package/src/match/voice/assets/number-5.wav +0 -0
  72. package/src/match/voice/assets/number-50.wav +0 -0
  73. package/src/match/voice/assets/number-6.wav +0 -0
  74. package/src/match/voice/assets/number-60.wav +0 -0
  75. package/src/match/voice/assets/number-7.wav +0 -0
  76. package/src/match/voice/assets/number-70.wav +0 -0
  77. package/src/match/voice/assets/number-8.wav +0 -0
  78. package/src/match/voice/assets/number-80.wav +0 -0
  79. package/src/match/voice/assets/number-9.wav +0 -0
  80. package/src/match/voice/assets/number-90.wav +0 -0
  81. package/src/match/voice/assets/number-lam.wav +0 -0
  82. package/src/match/voice/assets/number-mot.wav +0 -0
  83. package/src/match/voice/assets/score-deu-0.wav +0 -0
  84. package/src/match/voice/assets/score-deu-1.wav +0 -0
  85. package/src/match/voice/assets/score-deu-10.wav +0 -0
  86. package/src/match/voice/assets/score-deu-11.wav +0 -0
  87. package/src/match/voice/assets/score-deu-12.wav +0 -0
  88. package/src/match/voice/assets/score-deu-13.wav +0 -0
  89. package/src/match/voice/assets/score-deu-14.wav +0 -0
  90. package/src/match/voice/assets/score-deu-15.wav +0 -0
  91. package/src/match/voice/assets/score-deu-16.wav +0 -0
  92. package/src/match/voice/assets/score-deu-17.wav +0 -0
  93. package/src/match/voice/assets/score-deu-18.wav +0 -0
  94. package/src/match/voice/assets/score-deu-19.wav +0 -0
  95. package/src/match/voice/assets/score-deu-2.wav +0 -0
  96. package/src/match/voice/assets/score-deu-20.wav +0 -0
  97. package/src/match/voice/assets/score-deu-3.wav +0 -0
  98. package/src/match/voice/assets/score-deu-30.wav +0 -0
  99. package/src/match/voice/assets/score-deu-4.wav +0 -0
  100. package/src/match/voice/assets/score-deu-40.wav +0 -0
  101. package/src/match/voice/assets/score-deu-5.wav +0 -0
  102. package/src/match/voice/assets/score-deu-50.wav +0 -0
  103. package/src/match/voice/assets/score-deu-6.wav +0 -0
  104. package/src/match/voice/assets/score-deu-60.wav +0 -0
  105. package/src/match/voice/assets/score-deu-7.wav +0 -0
  106. package/src/match/voice/assets/score-deu-70.wav +0 -0
  107. package/src/match/voice/assets/score-deu-8.wav +0 -0
  108. package/src/match/voice/assets/score-deu-80.wav +0 -0
  109. package/src/match/voice/assets/score-deu-9.wav +0 -0
  110. package/src/match/voice/assets/score-deu-90.wav +0 -0
  111. package/src/match/voice/assets/score-deu-lam.wav +0 -0
  112. package/src/match/voice/assets/score-deu-mot.wav +0 -0
  113. package/src/match/voice/assets/server-1.wav +0 -0
  114. package/src/match/voice/assets/server-2.wav +0 -0
  115. package/src/match/voice/assets/service-change.wav +0 -0
  116. package/src/match/voice/assets/test-speaker.wav +0 -0
  117. package/src/match/voice/audio.ts +231 -0
  118. package/src/match/voice/bundled-assets.native.ts +47 -0
  119. package/src/match/voice/bundled-assets.ts +3 -0
  120. package/src/match/voice/gemini-engine.ts +167 -0
  121. package/src/match/voice/index.ts +7 -0
  122. package/src/match/voice/phrases.ts +185 -0
  123. package/src/match/voice/scheduler.ts +205 -0
  124. package/src/match/voice/service.ts +147 -0
  125. package/src/match/voice/types.ts +118 -0
  126. package/src/native-runtime.ts +241 -0
  127. package/src/plugin.ts +145 -0
  128. package/src/provider.tsx +176 -0
  129. package/src/version.ts +4 -0
package/src/engine.ts ADDED
@@ -0,0 +1,1332 @@
1
+ import type {
2
+ BootstrapInput,
3
+ EndReason,
4
+ LivestreamSessionProvider,
5
+ LivestreamState,
6
+ SdkRemoteConfig,
7
+ SdkSessionGrant,
8
+ SdkTelemetryCredentials,
9
+ SdkTelemetryEvent,
10
+ StartLivestreamInput,
11
+ } from "./contracts";
12
+ import {
13
+ ConsentRequiredError,
14
+ InvalidResponseError,
15
+ NetworkUnavailableError,
16
+ PermissionDeniedError,
17
+ PickleballExpoError,
18
+ SdkDisabledError,
19
+ SessionConflictError,
20
+ UnsupportedSdkError,
21
+ normalizeError,
22
+ } from "./errors";
23
+ import { isSdkRemoteConfig, isSdkVersionSupported } from "./contracts";
24
+
25
+ export interface RoomConnection {
26
+ serverUrl: string;
27
+ participantToken: string;
28
+ microphoneEnabled: boolean;
29
+ /** false: vào phòng nhưng chưa publish track (trạng thái standby). */
30
+ publish?: boolean;
31
+ }
32
+
33
+ export type RoomEvent =
34
+ | { type: "reconnecting" }
35
+ | { type: "reconnected" }
36
+ | { type: "disconnected"; reason?: string }
37
+ | { type: "media-error"; error: unknown }
38
+ /** Lệnh điều khiển từ dashboard qua LiveKit data channel ({"type":"cmd"}). */
39
+ | { type: "command"; action: string };
40
+
41
+ export interface RoomAdapter {
42
+ /** Bật/tắt publish track khi đã ở trong phòng (standby ↔ live). */
43
+ setPublishing?(enabled: boolean): Promise<void>;
44
+ setCaptureOrientation?(orientation: CaptureOrientation): Promise<void>;
45
+ listCameras?(): Promise<CameraDeviceInfo[]>;
46
+ selectCamera?(
47
+ deviceId: string,
48
+ facing: CameraDeviceInfo["facing"],
49
+ ): Promise<void>;
50
+ preparePreview(quality: 720 | 1080): Promise<unknown>;
51
+ connect(input: RoomConnection): Promise<void>;
52
+ disconnect(stopTracks?: boolean): Promise<void>;
53
+ switchCamera(): Promise<void>;
54
+ setMicrophoneEnabled(enabled: boolean): Promise<void>;
55
+ subscribe(listener: (event: RoomEvent) => void): () => void;
56
+ dispose(): Promise<void>;
57
+ }
58
+
59
+ export interface SdkRuntime {
60
+ assertSupported(): void;
61
+ requestPermissions(): Promise<boolean>;
62
+ activateKeepAwake(): void | Promise<void>;
63
+ deactivateKeepAwake(): void | Promise<void>;
64
+ }
65
+
66
+ export interface SdkStorage {
67
+ getItem(key: string): Promise<string | null>;
68
+ setItem(key: string, value: string): Promise<void>;
69
+ removeItem(key: string): Promise<void>;
70
+ }
71
+
72
+ export interface SdkNetwork {
73
+ isOnline(): boolean;
74
+ subscribe(listener: (online: boolean) => void): () => void;
75
+ }
76
+
77
+ export interface TelemetrySink {
78
+ send(
79
+ credentials: SdkTelemetryCredentials,
80
+ events: readonly SdkTelemetryEvent[],
81
+ ): Promise<void>;
82
+ dispose?(): void;
83
+ }
84
+
85
+ export interface EngineDependencies {
86
+ sessionProvider: LivestreamSessionProvider;
87
+ bootstrapInput: BootstrapInput;
88
+ roomFactory: () => RoomAdapter;
89
+ runtime: SdkRuntime;
90
+ storage: SdkStorage;
91
+ network: SdkNetwork;
92
+ telemetry?: TelemetrySink;
93
+ /** Thu chỉ số thiết bị; gọi mỗi nhịp heartbeat khi đang phát. */
94
+ deviceMetrics?: () => Promise<DeviceMetricsSample | null>;
95
+ now?: () => number;
96
+ }
97
+
98
+ export type CaptureOrientation = "portrait" | "landscape";
99
+
100
+ export interface CameraDeviceInfo {
101
+ deviceId: string;
102
+ label: string;
103
+ facing: "front" | "environment" | "unknown";
104
+ }
105
+
106
+ /** Chỉ số thiết bị gửi kèm heartbeat (trường trùng schema deviceMetrics). */
107
+ export interface DeviceMetricsSample {
108
+ batteryPct?: number;
109
+ batteryTempC?: number;
110
+ thermal?: number;
111
+ ramFreeMb?: number;
112
+ ramTotalMb?: number;
113
+ storageFreeGb?: number;
114
+ netType?: string;
115
+ linkMbps?: number;
116
+ rssi?: number;
117
+ connQuality?: string;
118
+ charging?: boolean;
119
+ }
120
+
121
+ export interface LivestreamSnapshot {
122
+ state: LivestreamState;
123
+ error: PickleballExpoError | null;
124
+ config: SdkRemoteConfig | null;
125
+ sessionId: string | null;
126
+ previewTrack: unknown;
127
+ isMicrophoneEnabled: boolean;
128
+ isCameraFront: boolean;
129
+ /** Explicit pre-live choice; null follows the window orientation. */
130
+ captureOrientation: CaptureOrientation | null;
131
+ /** Mẫu chỉ số thiết bị mới nhất (cập nhật mỗi heartbeat khi live). */
132
+ deviceMetrics: DeviceMetricsSample | null;
133
+ /** Cameras reported by the platform after preview was prepared. */
134
+ cameras: CameraDeviceInfo[];
135
+ /** Explicitly chosen camera; null when following facing (front/back). */
136
+ selectedCameraId: string | null;
137
+ elapsedMs: number;
138
+ isOnline: boolean;
139
+ }
140
+
141
+ const PENDING_END_KEY = "@pickleball/expo-sdk/pending-end";
142
+ /** Standby không phát quá lâu → tự kết thúc (config.standbyTimeoutMs ghi đè). */
143
+ const DEFAULT_STANDBY_TIMEOUT_MS = 15 * 60_000;
144
+ const activeStates = new Set<LivestreamState>([
145
+ "standby",
146
+ "live",
147
+ "reconnecting",
148
+ ]);
149
+ const inMemorySessionStates = new Set<LivestreamState>([
150
+ "connecting",
151
+ "standby",
152
+ "live",
153
+ "reconnecting",
154
+ "ending",
155
+ ]);
156
+
157
+ interface PendingEndRecord {
158
+ sessionId: string;
159
+ pendingEnd: EndReason | null;
160
+ }
161
+
162
+ interface SessionTermination {
163
+ markerReady: Promise<void>;
164
+ completion: Promise<void>;
165
+ }
166
+
167
+ interface PendingTelemetry {
168
+ credentials: SdkTelemetryCredentials;
169
+ event: SdkTelemetryEvent;
170
+ }
171
+
172
+ class StaleOperationError extends Error {}
173
+
174
+ export const LEGAL_LIVESTREAM_TRANSITIONS: Readonly<
175
+ Record<LivestreamState, readonly LivestreamState[]>
176
+ > = {
177
+ idle: ["preparing"],
178
+ preparing: ["preview", "ending", "error"],
179
+ preview: ["connecting", "preparing", "ending", "error"],
180
+ connecting: ["standby", "live", "ending", "error"],
181
+ standby: ["live", "reconnecting", "ending", "error"],
182
+ live: ["standby", "reconnecting", "ending", "error"],
183
+ reconnecting: ["standby", "live", "ending", "error"],
184
+ ending: ["ended", "error"],
185
+ ended: ["preparing"],
186
+ error: ["preparing", "reconnecting", "ending", "ended"],
187
+ };
188
+
189
+ export class LivestreamEngine {
190
+ private room?: RoomAdapter;
191
+ private readonly now: () => number;
192
+ private snapshot: LivestreamSnapshot;
193
+ private listeners = new Set<() => void>();
194
+ private preparePromise?: Promise<void>;
195
+ private startPromise?: Promise<void>;
196
+ private stopPromise?: Promise<void>;
197
+ private retryPromise?: Promise<void>;
198
+ private credentialRefreshPromise?: Promise<void>;
199
+ private pendingReplayPromise?: Promise<void>;
200
+ private startupRecoveryPromise?: Promise<void>;
201
+ private startupRecovered = false;
202
+ private pendingReplayError?: unknown;
203
+ private ownedSessionId?: string;
204
+ private commandTail: Promise<void> = Promise.resolve();
205
+ private commandCount = 0;
206
+ private roomUnsubscribe: () => void = () => undefined;
207
+ private networkUnsubscribe: () => void = () => undefined;
208
+ private networkSubscribed = false;
209
+ private latestAppState: "active" | "background" | "inactive" = "active";
210
+ private backgroundTimer?: ReturnType<typeof setTimeout>;
211
+ private standbyTimer?: ReturnType<typeof setTimeout>;
212
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
213
+ private heartbeatTimer?: ReturnType<typeof setInterval>;
214
+ private elapsedTimer?: ReturnType<typeof setInterval>;
215
+ private tokenRefreshTimer?: ReturnType<typeof setTimeout>;
216
+ private maxSessionTimer?: ReturnType<typeof setTimeout>;
217
+ private grant?: SdkSessionGrant;
218
+ private startedAt?: number;
219
+ /** Đang publish track (live) hay chỉ ngồi trong phòng (standby). */
220
+ private publishing = false;
221
+ /** Tổng thời gian đã phát trước lần tạm dừng gần nhất. */
222
+ private pausedElapsedMs = 0;
223
+ private sessionDeadline?: number;
224
+ private refreshRetryAttempt = 0;
225
+ private operationGeneration = 0;
226
+ private liveTimersStarted = false;
227
+ private keepAwakeActive = false;
228
+ private telemetryInFlight?: Promise<void>;
229
+ private pendingCriticalTelemetry?: PendingTelemetry;
230
+ private readonly sessionTerminations = new Map<string, SessionTermination>();
231
+ private disposePromise?: Promise<void>;
232
+ private disposed = false;
233
+
234
+ constructor(private readonly dependencies: EngineDependencies) {
235
+ this.now = dependencies.now ?? Date.now;
236
+ this.snapshot = {
237
+ state: "idle",
238
+ error: null,
239
+ config: null,
240
+ sessionId: null,
241
+ previewTrack: null,
242
+ isMicrophoneEnabled: true,
243
+ isCameraFront: true,
244
+ captureOrientation: null,
245
+ deviceMetrics: null,
246
+ cameras: [],
247
+ selectedCameraId: null,
248
+ elapsedMs: 0,
249
+ isOnline: dependencies.network.isOnline(),
250
+ };
251
+ }
252
+
253
+ getSnapshot = () => this.snapshot;
254
+
255
+ subscribe = (listener: () => void) => {
256
+ this.listeners.add(listener);
257
+ return () => this.listeners.delete(listener);
258
+ };
259
+
260
+ async initialize() {
261
+ if (this.disposed) return;
262
+ this.ensureNetworkSubscription();
263
+ if (this.startupRecovered) {
264
+ try {
265
+ await this.replayPendingEnd();
266
+ } catch (error) {
267
+ this.pendingReplayError = error;
268
+ }
269
+ return;
270
+ }
271
+ if (!this.startupRecoveryPromise) {
272
+ this.startupRecoveryPromise = this.recoverStartupSession()
273
+ .catch((error) => {
274
+ this.pendingReplayError = error;
275
+ })
276
+ .finally(() => {
277
+ this.startupRecovered = true;
278
+ this.startupRecoveryPromise = undefined;
279
+ });
280
+ }
281
+ await this.startupRecoveryPromise;
282
+ }
283
+
284
+ prepare() {
285
+ if (this.preparePromise) return this.preparePromise;
286
+ if (this.snapshot.state === "preview") return Promise.resolve();
287
+ if (
288
+ ["connecting", "live", "reconnecting", "ending"].includes(
289
+ this.snapshot.state,
290
+ ) ||
291
+ (this.snapshot.state === "error" && this.snapshot.sessionId)
292
+ ) {
293
+ return Promise.reject(
294
+ new SessionConflictError("Cannot prepare while a session is active"),
295
+ );
296
+ }
297
+ this.preparePromise = this.enqueueLifecycle(() => this.doPrepare()).finally(() => {
298
+ this.preparePromise = undefined;
299
+ });
300
+ return this.preparePromise;
301
+ }
302
+
303
+ private async doPrepare() {
304
+ const generation = this.operationGeneration;
305
+ this.ensureNotDisposed();
306
+ this.transition("preparing");
307
+ try {
308
+ await this.ensurePendingEndResolved();
309
+ this.ensureOperation(generation);
310
+ this.dependencies.runtime.assertSupported();
311
+ const bootstrap = await this.dependencies.sessionProvider.bootstrap(
312
+ this.dependencies.bootstrapInput,
313
+ );
314
+ this.ensureOperation(generation);
315
+ if (!isSdkRemoteConfig(bootstrap.config)) {
316
+ throw new InvalidResponseError(
317
+ "The session provider returned invalid SDK config",
318
+ );
319
+ }
320
+ if (!bootstrap.config.enabled || !bootstrap.rolloutEnabled) {
321
+ throw new SdkDisabledError(bootstrap.message);
322
+ }
323
+ if (
324
+ !isSdkVersionSupported(
325
+ this.dependencies.bootstrapInput.sdkVersion,
326
+ bootstrap.config.minSdkVersion,
327
+ )
328
+ ) {
329
+ throw new UnsupportedSdkError();
330
+ }
331
+ this.update({ config: bootstrap.config });
332
+ const permitted = await this.dependencies.runtime.requestPermissions();
333
+ this.ensureOperation(generation);
334
+ if (!permitted) {
335
+ throw new PermissionDeniedError();
336
+ }
337
+ const room = this.ensureRoom();
338
+ if (this.snapshot.captureOrientation) {
339
+ await room.setCaptureOrientation?.(this.snapshot.captureOrientation);
340
+ }
341
+ const previewTrack = await room.preparePreview(
342
+ bootstrap.config.videoQuality,
343
+ );
344
+ if (!this.isOperationCurrent(generation)) {
345
+ await this.releaseRoom();
346
+ throw new StaleOperationError();
347
+ }
348
+ let cameras: CameraDeviceInfo[] = [];
349
+ try {
350
+ cameras = (await room.listCameras?.()) ?? [];
351
+ } catch {
352
+ // Danh sách camera chỉ phục vụ UI chọn ống kính; lỗi không chặn preview.
353
+ }
354
+ if (!this.isOperationCurrent(generation)) {
355
+ await this.releaseRoom();
356
+ throw new StaleOperationError();
357
+ }
358
+ this.update({ previewTrack, cameras });
359
+ this.transition("preview");
360
+ } catch (error) {
361
+ if (
362
+ !this.isOperationCurrent(generation) ||
363
+ error instanceof StaleOperationError
364
+ ) {
365
+ return;
366
+ }
367
+ this.fail(error);
368
+ }
369
+ }
370
+
371
+ start(
372
+ input: StartLivestreamInput,
373
+ options?: { autoPublish?: boolean },
374
+ ) {
375
+ if (this.startPromise) return this.startPromise;
376
+ if (this.snapshot.state === "live" || this.snapshot.state === "standby") {
377
+ return Promise.resolve();
378
+ }
379
+ if (this.snapshot.state !== "preview" && !this.preparePromise) {
380
+ return Promise.reject(
381
+ new SessionConflictError("Livestream preview is not ready"),
382
+ );
383
+ }
384
+ this.startPromise = this.enqueueLifecycle(() =>
385
+ this.doStart(input, options?.autoPublish ?? true),
386
+ ).finally(() => {
387
+ this.startPromise = undefined;
388
+ });
389
+ return this.startPromise;
390
+ }
391
+
392
+ private async doStart(input: StartLivestreamInput, autoPublish: boolean) {
393
+ const generation = this.operationGeneration;
394
+ let activeGrant: SdkSessionGrant | undefined;
395
+ this.ensureNotDisposed();
396
+ this.ensureState("preview");
397
+ if (!input.consentVersion.trim()) throw new ConsentRequiredError();
398
+ if (!input.externalSessionId.trim() || !input.title.trim()) {
399
+ const error = new ConsentRequiredError("Session ID and title are required");
400
+ this.fail(error);
401
+ }
402
+ if (!this.dependencies.network.isOnline()) {
403
+ this.fail(new NetworkUnavailableError());
404
+ }
405
+ this.transition("connecting");
406
+ this.ensureBackgroundGraceTimer();
407
+ try {
408
+ await this.ensurePendingEndResolved();
409
+ this.ensureOperation(generation);
410
+ } catch (error) {
411
+ if (
412
+ !this.isOperationCurrent(generation) ||
413
+ error instanceof StaleOperationError
414
+ ) {
415
+ return;
416
+ }
417
+ this.fail(error);
418
+ }
419
+ try {
420
+ // Cờ standby để server KHÔNG bật egress/goLive khi app chỉ vào chờ.
421
+ const grant = await this.dependencies.sessionProvider.start({
422
+ ...input,
423
+ standby: !autoPublish,
424
+ });
425
+ activeGrant = grant;
426
+ if (!this.isOperationCurrent(generation)) {
427
+ await this.abandonGrant(grant);
428
+ return;
429
+ }
430
+ if (!isSdkRemoteConfig(grant.config)) {
431
+ await this.abandonGrant(grant);
432
+ throw new InvalidResponseError(
433
+ "The session provider returned invalid SDK config",
434
+ );
435
+ }
436
+ this.ownedSessionId = grant.sessionId;
437
+ this.grant = grant;
438
+ this.liveTimersStarted = false;
439
+ this.keepAwakeActive = false;
440
+ // Ghi sớm ý định phát để retry/reconnect khôi phục đúng trạng thái.
441
+ this.publishing = autoPublish;
442
+ this.pausedElapsedMs = 0;
443
+ this.update({
444
+ config: grant.config,
445
+ sessionId: grant.sessionId,
446
+ elapsedMs: 0,
447
+ });
448
+ await this.dependencies.storage.setItem(
449
+ PENDING_END_KEY,
450
+ JSON.stringify({ sessionId: grant.sessionId, pendingEnd: null }),
451
+ );
452
+ if (!this.isOperationCurrent(generation)) {
453
+ await this.abandonGrant(grant);
454
+ return;
455
+ }
456
+ await this.requireRoom().connect({
457
+ serverUrl: grant.serverUrl,
458
+ participantToken: grant.participantToken,
459
+ microphoneEnabled: this.snapshot.isMicrophoneEnabled,
460
+ publish: autoPublish,
461
+ });
462
+ if (!this.isOperationCurrent(generation)) {
463
+ await this.abandonGrant(grant);
464
+ return;
465
+ }
466
+ await this.enterSession(generation, autoPublish);
467
+ } catch (error) {
468
+ if (
469
+ !this.isOperationCurrent(generation) ||
470
+ error instanceof StaleOperationError
471
+ ) {
472
+ if (activeGrant) await this.abandonGrant(activeGrant);
473
+ return;
474
+ }
475
+ this.fail(error);
476
+ }
477
+ }
478
+
479
+ stop(reason: EndReason = "user") {
480
+ if (this.stopPromise) return this.stopPromise;
481
+ if (this.snapshot.state === "ended" || this.snapshot.state === "idle") {
482
+ return Promise.resolve();
483
+ }
484
+ this.stopPromise = this.enqueueLifecycle(() => this.doStop(reason)).finally(() => {
485
+ this.stopPromise = undefined;
486
+ });
487
+ return this.stopPromise;
488
+ }
489
+
490
+ private async doStop(reason: EndReason) {
491
+ if (this.disposed) return;
492
+ const sessionId = this.ownedSessionId;
493
+ const termination = sessionId
494
+ ? this.ensureSessionTermination(sessionId, reason)
495
+ : undefined;
496
+ this.transition("ending");
497
+ this.clearTimers();
498
+ const roomCleanup = this.releaseRoom(termination?.markerReady);
499
+ await Promise.allSettled([
500
+ roomCleanup,
501
+ this.dependencies.runtime.deactivateKeepAwake(),
502
+ ]);
503
+ this.keepAwakeActive = false;
504
+ if (sessionId) {
505
+ await termination?.completion;
506
+ void this.sendTelemetry("session_ended", { reason });
507
+ }
508
+ this.grant = undefined;
509
+ this.ownedSessionId = undefined;
510
+ this.startedAt = undefined;
511
+ this.publishing = false;
512
+ this.pausedElapsedMs = 0;
513
+ this.update({ previewTrack: null, sessionId: null });
514
+ this.transition("ended");
515
+ }
516
+
517
+ retry() {
518
+ if (this.retryPromise) return this.retryPromise;
519
+ this.retryPromise = this.enqueueLifecycle(() => this.doRetry()).finally(() => {
520
+ this.retryPromise = undefined;
521
+ });
522
+ return this.retryPromise;
523
+ }
524
+
525
+ private async doRetry() {
526
+ const generation = this.operationGeneration;
527
+ this.ensureNotDisposed();
528
+ if (this.snapshot.state === "error" && !this.snapshot.sessionId) {
529
+ this.update({ error: null });
530
+ return this.prepare();
531
+ }
532
+ if (this.snapshot.state === "error" && this.snapshot.sessionId) {
533
+ this.transition("reconnecting");
534
+ }
535
+ this.ensureState("reconnecting");
536
+ const sessionId = this.snapshot.sessionId;
537
+ if (!sessionId || !this.grant) throw new Error("Missing session grant");
538
+ const activeGrant = this.grant;
539
+ try {
540
+ await this.refreshCredentials(generation);
541
+ this.ensureOperation(generation);
542
+ await this.requireRoom().connect({
543
+ serverUrl: this.grant.serverUrl,
544
+ participantToken: this.grant.participantToken,
545
+ microphoneEnabled: this.snapshot.isMicrophoneEnabled,
546
+ publish: this.publishing,
547
+ });
548
+ this.ensureOperation(generation);
549
+ this.clearReconnectTimer();
550
+ await this.enterSession(generation, this.publishing);
551
+ } catch (error) {
552
+ if (
553
+ !this.isOperationCurrent(generation) ||
554
+ error instanceof StaleOperationError
555
+ ) {
556
+ await this.abandonGrant(activeGrant);
557
+ return;
558
+ }
559
+ this.fail(error);
560
+ }
561
+ }
562
+
563
+ async switchCamera() {
564
+ if (!this.snapshot.previewTrack) return;
565
+ const generation = this.operationGeneration;
566
+ await this.requireRoom().switchCamera();
567
+ if (!this.isOperationCurrent(generation)) return;
568
+ this.update({
569
+ isCameraFront: !this.snapshot.isCameraFront,
570
+ selectedCameraId: null,
571
+ });
572
+ }
573
+
574
+ /** Chọn một ống kính cụ thể từ snapshot.cameras. */
575
+ async selectCamera(deviceId: string) {
576
+ if (!this.snapshot.previewTrack) return;
577
+ if (deviceId === this.snapshot.selectedCameraId) return;
578
+ const camera = this.snapshot.cameras.find(
579
+ (item) => item.deviceId === deviceId,
580
+ );
581
+ if (!camera) return;
582
+ const room = this.requireRoom();
583
+ if (!room.selectCamera) return;
584
+ const generation = this.operationGeneration;
585
+ await room.selectCamera(camera.deviceId, camera.facing);
586
+ if (!this.isOperationCurrent(generation)) return;
587
+ this.update({
588
+ selectedCameraId: camera.deviceId,
589
+ ...(camera.facing === "unknown"
590
+ ? {}
591
+ : { isCameraFront: camera.facing === "front" }),
592
+ });
593
+ }
594
+
595
+ /** Chọn kiểu quay trước khi live; không đổi khi phiên đang hoạt động. */
596
+ async setCaptureOrientation(orientation: CaptureOrientation) {
597
+ if (orientation === this.snapshot.captureOrientation) return;
598
+ if (this.snapshot.sessionId || activeStates.has(this.snapshot.state)) {
599
+ return;
600
+ }
601
+ const generation = this.operationGeneration;
602
+ if (this.snapshot.previewTrack) {
603
+ await this.requireRoom().setCaptureOrientation?.(orientation);
604
+ if (!this.isOperationCurrent(generation)) return;
605
+ }
606
+ this.update({ captureOrientation: orientation });
607
+ }
608
+
609
+ async setMicrophoneEnabled(enabled: boolean) {
610
+ const generation = this.operationGeneration;
611
+ await this.requireRoom().setMicrophoneEnabled(enabled);
612
+ if (!this.isOperationCurrent(generation)) return;
613
+ this.update({ isMicrophoneEnabled: enabled });
614
+ }
615
+
616
+ handleRoomEvent(event: RoomEvent) {
617
+ if (this.disposed) return;
618
+ // 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 là hành động riêng (nút trên app / Kết thúc trên dashboard).
621
+ if (event.type === "command") {
622
+ if (event.action === "start") void this.startPublishing();
623
+ else if (event.action === "stop") void this.stopPublishing();
624
+ return;
625
+ }
626
+ if (
627
+ event.type === "reconnecting" &&
628
+ (this.snapshot.state === "live" || this.snapshot.state === "standby")
629
+ ) {
630
+ this.transition("reconnecting");
631
+ void this.sendTelemetry("reconnect", { phase: "started" });
632
+ const timeout = this.snapshot.config?.reconnectTimeoutMs ?? 30_000;
633
+ this.clearReconnectTimer();
634
+ this.reconnectTimer = setTimeout(() => {
635
+ void this.stop("network_timeout");
636
+ }, timeout);
637
+ return;
638
+ }
639
+ if (event.type === "reconnected" && this.snapshot.state === "reconnecting") {
640
+ this.clearReconnectTimer();
641
+ this.transition(this.publishing ? "live" : "standby");
642
+ void this.sendTelemetry("reconnect", { phase: "recovered" });
643
+ return;
644
+ }
645
+ if (event.type === "media-error") this.recordError(event.error);
646
+ if (
647
+ event.type === "disconnected" &&
648
+ activeStates.has(this.snapshot.state)
649
+ ) {
650
+ this.handleRoomEvent({ type: "reconnecting" });
651
+ }
652
+ }
653
+
654
+ handleAppState(state: "active" | "background" | "inactive") {
655
+ if (this.disposed) return;
656
+ this.latestAppState = state;
657
+ if (state === "active") {
658
+ if (this.backgroundTimer) clearTimeout(this.backgroundTimer);
659
+ this.backgroundTimer = undefined;
660
+ return;
661
+ }
662
+ this.ensureBackgroundGraceTimer();
663
+ }
664
+
665
+ private ensureBackgroundGraceTimer() {
666
+ if (
667
+ this.latestAppState === "active" ||
668
+ !["connecting", "standby", "live", "reconnecting"].includes(
669
+ this.snapshot.state,
670
+ ) ||
671
+ this.backgroundTimer
672
+ ) {
673
+ return;
674
+ }
675
+ const grace = this.snapshot.config?.backgroundGraceMs ?? 15_000;
676
+ this.backgroundTimer = setTimeout(() => {
677
+ this.backgroundTimer = undefined;
678
+ if (this.latestAppState !== "active") {
679
+ void this.stop("background_timeout");
680
+ }
681
+ }, grace);
682
+ }
683
+
684
+ dispose(): Promise<void> {
685
+ if (this.disposePromise) return this.disposePromise;
686
+ if (this.disposed) return Promise.resolve();
687
+ this.disposed = true;
688
+ this.operationGeneration += 1;
689
+ const sessionId = this.ownedSessionId;
690
+ let releaseEnd: () => void = () => undefined;
691
+ const endBarrier = new Promise<void>((resolve) => {
692
+ releaseEnd = resolve;
693
+ });
694
+ const termination = sessionId
695
+ ? this.ensureSessionTermination(sessionId, "error", endBarrier)
696
+ : undefined;
697
+ const roomCleanup = this.releaseRoom(termination?.markerReady);
698
+ this.clearTimers();
699
+ this.dependencies.telemetry?.dispose?.();
700
+ this.pendingCriticalTelemetry = undefined;
701
+ this.networkUnsubscribe();
702
+ this.listeners.clear();
703
+ this.keepAwakeActive = false;
704
+ this.grant = undefined;
705
+ this.ownedSessionId = undefined;
706
+ this.disposePromise = (async () => {
707
+ await termination?.markerReady;
708
+ await Promise.allSettled([
709
+ roomCleanup,
710
+ this.dependencies.runtime.deactivateKeepAwake(),
711
+ ]);
712
+ releaseEnd();
713
+ await termination?.completion;
714
+ })();
715
+ return this.disposePromise;
716
+ }
717
+
718
+ private async replayPendingEnd() {
719
+ if (this.ownsInMemorySession()) return;
720
+ if (this.pendingReplayPromise) return this.pendingReplayPromise;
721
+ this.pendingReplayPromise = this.doReplayPendingEnd().finally(() => {
722
+ this.pendingReplayPromise = undefined;
723
+ });
724
+ return this.pendingReplayPromise;
725
+ }
726
+
727
+ private async doReplayPendingEnd() {
728
+ const pending = await this.readPendingEnd();
729
+ if (!pending || pending.pendingEnd === null) return;
730
+ if (!this.dependencies.network.isOnline()) return;
731
+ await this.dependencies.sessionProvider.end({
732
+ sessionId: pending.sessionId,
733
+ reason: pending.pendingEnd ?? "error",
734
+ });
735
+ await this.dependencies.storage.removeItem(PENDING_END_KEY);
736
+ this.pendingReplayError = undefined;
737
+ }
738
+
739
+ private async recoverStartupSession() {
740
+ let pending = await this.readPendingEnd();
741
+ if (!pending || this.ownsInMemorySession()) return;
742
+ if (pending.pendingEnd === null) {
743
+ pending = { sessionId: pending.sessionId, pendingEnd: "error" };
744
+ await this.dependencies.storage.setItem(
745
+ PENDING_END_KEY,
746
+ JSON.stringify(pending),
747
+ );
748
+ }
749
+ if (!this.dependencies.network.isOnline()) return;
750
+ await this.dependencies.sessionProvider.end({
751
+ sessionId: pending.sessionId,
752
+ reason: pending.pendingEnd ?? "error",
753
+ });
754
+ await this.dependencies.storage.removeItem(PENDING_END_KEY);
755
+ this.pendingReplayError = undefined;
756
+ }
757
+
758
+ private async ensurePendingEndResolved() {
759
+ await this.initialize();
760
+ const pending = await this.readPendingEnd();
761
+ if (!pending) return;
762
+ if (!this.dependencies.network.isOnline()) {
763
+ throw new NetworkUnavailableError(
764
+ "Connect to the network to finish the previous livestream",
765
+ );
766
+ }
767
+ if (this.pendingReplayError instanceof TypeError) {
768
+ throw new NetworkUnavailableError(undefined, {
769
+ cause: this.pendingReplayError,
770
+ });
771
+ }
772
+ if (
773
+ this.snapshot.state === "connecting" &&
774
+ !this.ownedSessionId &&
775
+ pending.pendingEnd !== null
776
+ ) {
777
+ try {
778
+ await this.dependencies.sessionProvider.end({
779
+ sessionId: pending.sessionId,
780
+ reason: pending.pendingEnd,
781
+ });
782
+ await this.dependencies.storage.removeItem(PENDING_END_KEY);
783
+ this.pendingReplayError = undefined;
784
+ return;
785
+ } catch (error) {
786
+ this.pendingReplayError = error;
787
+ if (error instanceof TypeError) {
788
+ throw new NetworkUnavailableError(undefined, { cause: error });
789
+ }
790
+ }
791
+ }
792
+ throw new SessionConflictError(
793
+ pending.pendingEnd === null
794
+ ? "Another livestream session is still active"
795
+ : "The previous livestream is still pending end",
796
+ );
797
+ }
798
+
799
+ private async readPendingEnd(): Promise<PendingEndRecord | null> {
800
+ const raw = await this.dependencies.storage.getItem(PENDING_END_KEY);
801
+ if (!raw) return null;
802
+ try {
803
+ const value = JSON.parse(raw) as {
804
+ sessionId?: unknown;
805
+ pendingEnd?: unknown;
806
+ };
807
+ if (typeof value.sessionId !== "string") throw new TypeError();
808
+ if (value.pendingEnd === null) {
809
+ return { sessionId: value.sessionId, pendingEnd: null };
810
+ }
811
+ if (
812
+ [
813
+ "user",
814
+ "background_timeout",
815
+ "network_timeout",
816
+ "standby_timeout",
817
+ "error",
818
+ ].includes(String(value.pendingEnd))
819
+ ) {
820
+ return {
821
+ sessionId: value.sessionId,
822
+ pendingEnd: value.pendingEnd as EndReason,
823
+ };
824
+ }
825
+ } catch {
826
+ // Invalid bounded recovery data can be safely discarded.
827
+ }
828
+ await this.dependencies.storage.removeItem(PENDING_END_KEY);
829
+ return null;
830
+ }
831
+
832
+ private ownsInMemorySession() {
833
+ return Boolean(this.ownedSessionId) || inMemorySessionStates.has(this.snapshot.state);
834
+ }
835
+
836
+ private startTimers() {
837
+ if (this.liveTimersStarted) return;
838
+ this.liveTimersStarted = true;
839
+ const interval = Math.max(
840
+ 10_000,
841
+ this.snapshot.config?.telemetryIntervalMs ?? 60_000,
842
+ );
843
+ this.heartbeatTimer = setInterval(() => {
844
+ void this.sendHeartbeat();
845
+ }, interval);
846
+ (this.heartbeatTimer as unknown as { unref?: () => void }).unref?.();
847
+ this.elapsedTimer = setInterval(() => {
848
+ if (this.startedAt !== undefined) {
849
+ this.update({ elapsedMs: this.now() - this.startedAt });
850
+ }
851
+ }, 1_000);
852
+ (this.elapsedTimer as unknown as { unref?: () => void }).unref?.();
853
+ this.scheduleTokenRefresh();
854
+ this.scheduleMaxSessionDuration();
855
+ }
856
+
857
+ private clearTimers() {
858
+ if (this.backgroundTimer) clearTimeout(this.backgroundTimer);
859
+ this.clearStandbyTimer();
860
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
861
+ if (this.elapsedTimer) clearInterval(this.elapsedTimer);
862
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
863
+ if (this.maxSessionTimer) clearTimeout(this.maxSessionTimer);
864
+ this.clearReconnectTimer();
865
+ this.backgroundTimer = undefined;
866
+ this.heartbeatTimer = undefined;
867
+ this.elapsedTimer = undefined;
868
+ this.tokenRefreshTimer = undefined;
869
+ this.maxSessionTimer = undefined;
870
+ this.sessionDeadline = undefined;
871
+ this.liveTimersStarted = false;
872
+ }
873
+
874
+ private clearReconnectTimer() {
875
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
876
+ this.reconnectTimer = undefined;
877
+ }
878
+
879
+ /** Heartbeat kèm chỉ số thiết bị (nếu collector được cung cấp). */
880
+ private async sendHeartbeat() {
881
+ let metrics: DeviceMetricsSample | null = null;
882
+ try {
883
+ metrics = (await this.dependencies.deviceMetrics?.()) ?? null;
884
+ } catch {
885
+ metrics = null;
886
+ }
887
+ if (this.disposed) return;
888
+ if (metrics) this.update({ deviceMetrics: metrics });
889
+ return this.sendTelemetry(
890
+ "heartbeat",
891
+ metrics ? { ...metrics } : undefined,
892
+ );
893
+ }
894
+
895
+ private async sendTelemetry(
896
+ name: SdkTelemetryEvent["name"],
897
+ data?: Record<string, unknown>,
898
+ ) {
899
+ if (this.disposed || !this.dependencies.telemetry || !this.grant) return;
900
+ const grant = this.grant;
901
+ const pending: PendingTelemetry = {
902
+ credentials: grant.telemetry,
903
+ event: {
904
+ sessionId: grant.sessionId,
905
+ at: this.now(),
906
+ name,
907
+ ...(data ? { data } : {}),
908
+ },
909
+ };
910
+ if (this.telemetryInFlight) {
911
+ if (name === "session_ended" && !this.pendingCriticalTelemetry) {
912
+ this.pendingCriticalTelemetry = pending;
913
+ }
914
+ return this.telemetryInFlight;
915
+ }
916
+ return this.startTelemetrySend(pending);
917
+ }
918
+
919
+ private startTelemetrySend(pending: PendingTelemetry) {
920
+ const operation = (async () => {
921
+ try {
922
+ await this.dependencies.telemetry?.send(pending.credentials, [
923
+ pending.event,
924
+ ]);
925
+ } catch (error) {
926
+ if ((error as { status?: unknown })?.status === 401) {
927
+ try {
928
+ await this.refreshCredentials();
929
+ if (this.grant) {
930
+ await this.dependencies.telemetry?.send(this.grant.telemetry, [
931
+ pending.event,
932
+ ]);
933
+ }
934
+ } catch {
935
+ // Telemetry must never interrupt the livestream lifecycle.
936
+ }
937
+ }
938
+ }
939
+ })();
940
+ const tracked = operation.finally(() => {
941
+ if (this.telemetryInFlight !== tracked) return;
942
+ this.telemetryInFlight = undefined;
943
+ const critical = this.pendingCriticalTelemetry;
944
+ this.pendingCriticalTelemetry = undefined;
945
+ if (critical && !this.disposed) this.startTelemetrySend(critical);
946
+ });
947
+ this.telemetryInFlight = tracked;
948
+ return this.telemetryInFlight;
949
+ }
950
+
951
+ private refreshCredentials(generation = this.operationGeneration) {
952
+ if (this.credentialRefreshPromise) return this.credentialRefreshPromise;
953
+ const sessionId = this.snapshot.sessionId;
954
+ if (!sessionId || !this.grant) return Promise.resolve();
955
+ this.credentialRefreshPromise = this.dependencies.sessionProvider
956
+ .refresh(sessionId)
957
+ .then((refreshed) => {
958
+ this.ensureOperation(generation);
959
+ if (!isSdkRemoteConfig(refreshed.config)) {
960
+ throw new InvalidResponseError(
961
+ "The session provider returned invalid SDK config",
962
+ );
963
+ }
964
+ if (
965
+ !this.grant ||
966
+ this.snapshot.sessionId !== sessionId ||
967
+ !activeStates.has(this.snapshot.state)
968
+ ) {
969
+ return;
970
+ }
971
+ this.grant = {
972
+ ...this.grant,
973
+ participantToken:
974
+ refreshed.participantToken ?? this.grant.participantToken,
975
+ tokenExpiresAt: refreshed.tokenExpiresAt ?? this.grant.tokenExpiresAt,
976
+ telemetry: refreshed.telemetry ?? this.grant.telemetry,
977
+ config: refreshed.config,
978
+ };
979
+ this.update({ config: refreshed.config });
980
+ this.refreshRetryAttempt = 0;
981
+ if (this.liveTimersStarted) {
982
+ this.restartHeartbeat();
983
+ this.scheduleTokenRefresh();
984
+ this.scheduleMaxSessionDuration();
985
+ }
986
+ })
987
+ .finally(() => {
988
+ this.credentialRefreshPromise = undefined;
989
+ });
990
+ return this.credentialRefreshPromise;
991
+ }
992
+
993
+ private transition(state: LivestreamState) {
994
+ const previous = this.snapshot.state;
995
+ if (previous === state) return;
996
+ if (!LEGAL_LIVESTREAM_TRANSITIONS[previous].includes(state)) {
997
+ throw new SessionConflictError(
998
+ `Illegal livestream transition: ${previous} -> ${state}`,
999
+ );
1000
+ }
1001
+ this.update({ state, error: state === "error" ? this.snapshot.error : null });
1002
+ // Ngồi standby quá lâu không phát → tự kết thúc để cắt participant
1003
+ // minutes LiveKit + pin (timer reset mỗi lần rời/vào lại standby).
1004
+ if (state === "standby") this.startStandbyTimer();
1005
+ else this.clearStandbyTimer();
1006
+ if (this.snapshot.sessionId) {
1007
+ void this.sendTelemetry("state_changed", { from: previous, to: state });
1008
+ }
1009
+ }
1010
+
1011
+ private startStandbyTimer() {
1012
+ this.clearStandbyTimer();
1013
+ const timeout =
1014
+ this.snapshot.config?.standbyTimeoutMs ?? DEFAULT_STANDBY_TIMEOUT_MS;
1015
+ this.standbyTimer = setTimeout(() => {
1016
+ this.standbyTimer = undefined;
1017
+ if (this.snapshot.state === "standby") {
1018
+ void this.stop("standby_timeout");
1019
+ }
1020
+ }, timeout);
1021
+ (this.standbyTimer as unknown as { unref?: () => void }).unref?.();
1022
+ }
1023
+
1024
+ private clearStandbyTimer() {
1025
+ if (this.standbyTimer) clearTimeout(this.standbyTimer);
1026
+ this.standbyTimer = undefined;
1027
+ }
1028
+
1029
+ private fail(error: unknown): never {
1030
+ const normalized = this.recordError(error);
1031
+ throw normalized;
1032
+ }
1033
+
1034
+ private recordError(error: unknown) {
1035
+ const normalized = normalizeError(error);
1036
+ const state = this.snapshot.state;
1037
+ if (
1038
+ state !== "error" &&
1039
+ !LEGAL_LIVESTREAM_TRANSITIONS[state].includes("error")
1040
+ ) {
1041
+ return normalized;
1042
+ }
1043
+ this.update({ error: normalized });
1044
+ this.transition("error");
1045
+ void this.sendTelemetry("error", { code: normalized.code });
1046
+ return normalized;
1047
+ }
1048
+
1049
+ private update(patch: Partial<LivestreamSnapshot>) {
1050
+ if (this.disposed) return;
1051
+ this.snapshot = { ...this.snapshot, ...patch };
1052
+ for (const listener of this.listeners) listener();
1053
+ }
1054
+
1055
+ private ensureState(expected: LivestreamState) {
1056
+ if (this.snapshot.state !== expected) {
1057
+ throw new Error(`Expected livestream state ${expected}`);
1058
+ }
1059
+ }
1060
+
1061
+ private ensureNotDisposed() {
1062
+ if (this.disposed) throw new Error("Livestream engine has been disposed");
1063
+ }
1064
+
1065
+ private ensureRoom() {
1066
+ if (!this.room) {
1067
+ this.room = this.dependencies.roomFactory();
1068
+ this.roomUnsubscribe = this.room.subscribe((event) =>
1069
+ this.handleRoomEvent(event),
1070
+ );
1071
+ }
1072
+ return this.room;
1073
+ }
1074
+
1075
+ private ensureNetworkSubscription() {
1076
+ if (this.networkSubscribed || this.disposed) return;
1077
+ this.networkSubscribed = true;
1078
+ this.networkUnsubscribe = this.dependencies.network.subscribe((online) => {
1079
+ if (this.disposed) return;
1080
+ this.update({ isOnline: online });
1081
+ if (online && this.startupRecovered) {
1082
+ void this.replayPendingEnd().catch((error) => {
1083
+ this.pendingReplayError = error;
1084
+ });
1085
+ }
1086
+ });
1087
+ }
1088
+
1089
+ private requireRoom() {
1090
+ if (!this.room) throw new Error("Livestream Room has not been prepared");
1091
+ return this.room;
1092
+ }
1093
+
1094
+ private releaseRoom(
1095
+ beforeDispose: Promise<unknown> = Promise.resolve(),
1096
+ ): Promise<void> {
1097
+ const unsubscribe = this.roomUnsubscribe;
1098
+ this.roomUnsubscribe = () => undefined;
1099
+ const room = this.room;
1100
+ this.room = undefined;
1101
+ try {
1102
+ unsubscribe();
1103
+ } catch {
1104
+ // A consumer listener must not prevent releasing native media resources.
1105
+ }
1106
+ if (!room) return Promise.resolve();
1107
+ return beforeDispose.then(() => room.dispose());
1108
+ }
1109
+
1110
+ private enqueueLifecycle<T>(command: () => Promise<T>): Promise<T> {
1111
+ this.commandCount += 1;
1112
+ let result: Promise<T>;
1113
+ if (this.commandCount === 1) {
1114
+ try {
1115
+ result = Promise.resolve(command());
1116
+ } catch (error) {
1117
+ result = Promise.reject(error);
1118
+ }
1119
+ } else {
1120
+ result = this.commandTail.then(command, command);
1121
+ }
1122
+ this.commandTail = result.then(
1123
+ () => undefined,
1124
+ () => undefined,
1125
+ );
1126
+ return result.finally(() => {
1127
+ this.commandCount -= 1;
1128
+ });
1129
+ }
1130
+
1131
+ private scheduleTokenRefresh(delayOverride?: number) {
1132
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
1133
+ this.tokenRefreshTimer = undefined;
1134
+ if (!this.grant || !this.snapshot.sessionId) return;
1135
+ const delay =
1136
+ delayOverride ??
1137
+ Math.max(1_000, this.grant.tokenExpiresAt - this.now() - 60_000);
1138
+ this.tokenRefreshTimer = setTimeout(() => {
1139
+ void this.refreshCredentials().catch(() => {
1140
+ if (this.disposed) return;
1141
+ const retryDelay = Math.min(
1142
+ 30_000,
1143
+ 1_000 * 2 ** Math.min(this.refreshRetryAttempt, 5),
1144
+ );
1145
+ this.refreshRetryAttempt += 1;
1146
+ this.scheduleTokenRefresh(retryDelay);
1147
+ });
1148
+ }, delay);
1149
+ (this.tokenRefreshTimer as unknown as { unref?: () => void }).unref?.();
1150
+ }
1151
+
1152
+ private scheduleMaxSessionDuration() {
1153
+ if (this.startedAt === undefined || !this.snapshot.config) return;
1154
+ const proposed =
1155
+ this.startedAt + this.snapshot.config.maxSessionDurationMs;
1156
+ this.sessionDeadline = Math.min(this.sessionDeadline ?? proposed, proposed);
1157
+ if (this.maxSessionTimer) clearTimeout(this.maxSessionTimer);
1158
+ const delay = Math.max(0, this.sessionDeadline - this.now());
1159
+ this.maxSessionTimer = setTimeout(() => {
1160
+ void this.stop("error");
1161
+ }, delay);
1162
+ (this.maxSessionTimer as unknown as { unref?: () => void }).unref?.();
1163
+ }
1164
+
1165
+ private restartHeartbeat() {
1166
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
1167
+ const interval = Math.max(
1168
+ 10_000,
1169
+ this.snapshot.config?.telemetryIntervalMs ?? 60_000,
1170
+ );
1171
+ this.heartbeatTimer = setInterval(() => {
1172
+ void this.sendHeartbeat();
1173
+ }, interval);
1174
+ (this.heartbeatTimer as unknown as { unref?: () => void }).unref?.();
1175
+ }
1176
+
1177
+ private async enterSession(generation: number, publish: boolean) {
1178
+ this.ensureOperation(generation);
1179
+ if (!this.keepAwakeActive) {
1180
+ await this.dependencies.runtime.activateKeepAwake();
1181
+ if (!this.isOperationCurrent(generation)) {
1182
+ await this.dependencies.runtime.deactivateKeepAwake();
1183
+ throw new StaleOperationError();
1184
+ }
1185
+ this.keepAwakeActive = true;
1186
+ }
1187
+ this.publishing = publish;
1188
+ if (publish && this.startedAt === undefined) {
1189
+ // elapsedMs đếm thời gian PHÁT: cộng dồn qua các lần tạm dừng.
1190
+ this.startedAt = this.now() - this.pausedElapsedMs;
1191
+ this.sessionDeadline = undefined;
1192
+ this.update({ elapsedMs: this.pausedElapsedMs });
1193
+ }
1194
+ this.startTimers();
1195
+ this.transition(publish ? "live" : "standby");
1196
+ this.ensureBackgroundGraceTimer();
1197
+ }
1198
+
1199
+ /** Bắt đầu phát khi đang standby (từ nút trên app hoặc lệnh dashboard). */
1200
+ async startPublishing() {
1201
+ if (this.disposed || this.snapshot.state !== "standby") return;
1202
+ const generation = this.operationGeneration;
1203
+ try {
1204
+ // Server bật recording egress + chuyển status live TRƯỚC khi publish
1205
+ // track — phiên standby không được server provision sẵn egress nữa.
1206
+ const grant = this.grant;
1207
+ if (grant && this.dependencies.sessionProvider.publish) {
1208
+ await this.dependencies.sessionProvider.publish(grant.sessionId);
1209
+ if (
1210
+ !this.isOperationCurrent(generation) ||
1211
+ this.snapshot.state !== "standby"
1212
+ ) {
1213
+ return;
1214
+ }
1215
+ }
1216
+ const room = this.requireRoom();
1217
+ if (room.setPublishing) {
1218
+ await room.setPublishing(true);
1219
+ } else if (this.grant) {
1220
+ await room.connect({
1221
+ serverUrl: this.grant.serverUrl,
1222
+ participantToken: this.grant.participantToken,
1223
+ microphoneEnabled: this.snapshot.isMicrophoneEnabled,
1224
+ publish: true,
1225
+ });
1226
+ }
1227
+ if (!this.isOperationCurrent(generation)) return;
1228
+ this.publishing = true;
1229
+ this.startedAt = this.now() - this.pausedElapsedMs;
1230
+ this.update({ elapsedMs: this.pausedElapsedMs });
1231
+ this.transition("live");
1232
+ void this.sendTelemetry("state_changed", { state: "live" });
1233
+ } catch (error) {
1234
+ if (
1235
+ !this.isOperationCurrent(generation) ||
1236
+ error instanceof StaleOperationError
1237
+ ) {
1238
+ return;
1239
+ }
1240
+ this.recordError(error);
1241
+ }
1242
+ }
1243
+
1244
+ /** Tạm dừng phát: ngắt kết nối LiveKit hoàn toàn về standby, KHÔNG kết thúc phiên. */
1245
+ async stopPublishing() {
1246
+ if (this.disposed || this.snapshot.state !== "live") return;
1247
+ const generation = this.operationGeneration;
1248
+ const room = this.requireRoom();
1249
+ if (room.setPublishing) {
1250
+ await room.setPublishing(false);
1251
+ } else {
1252
+ await room.disconnect(false);
1253
+ }
1254
+ if (!this.isOperationCurrent(generation)) return;
1255
+ this.publishing = false;
1256
+ this.pausedElapsedMs =
1257
+ this.startedAt !== undefined ? this.now() - this.startedAt : 0;
1258
+ this.startedAt = undefined;
1259
+ this.update({ elapsedMs: this.pausedElapsedMs });
1260
+ this.transition("standby");
1261
+ void this.sendTelemetry("state_changed", { state: "standby" });
1262
+ // Báo server dừng egress; lỗi mạng thì watchdog server tự dọn sau.
1263
+ const grant = this.grant;
1264
+ if (grant && this.dependencies.sessionProvider.unpublish) {
1265
+ await this.dependencies.sessionProvider
1266
+ .unpublish(grant.sessionId)
1267
+ .catch(() => undefined);
1268
+ }
1269
+ }
1270
+
1271
+ private ensureSessionTermination(
1272
+ sessionId: string,
1273
+ reason: EndReason,
1274
+ beforeEnd: Promise<unknown> = Promise.resolve(),
1275
+ ): SessionTermination {
1276
+ const existing = this.sessionTerminations.get(sessionId);
1277
+ if (existing) return existing;
1278
+ let markerReady: Promise<void>;
1279
+ try {
1280
+ markerReady = Promise.resolve(
1281
+ this.dependencies.storage.setItem(
1282
+ PENDING_END_KEY,
1283
+ JSON.stringify({ sessionId, pendingEnd: reason }),
1284
+ ),
1285
+ ).catch(() => {
1286
+ // Server-side termination is still useful if recovery storage fails.
1287
+ });
1288
+ } catch {
1289
+ markerReady = Promise.resolve();
1290
+ }
1291
+ const completion = Promise.all([markerReady, beforeEnd]).then(async () => {
1292
+ try {
1293
+ await this.dependencies.sessionProvider.end({ sessionId, reason });
1294
+ await this.dependencies.storage.removeItem(PENDING_END_KEY);
1295
+ } catch {
1296
+ // The pending-end marker remains for the next launch or online replay.
1297
+ }
1298
+ });
1299
+ const termination = { markerReady, completion };
1300
+ this.sessionTerminations.set(sessionId, termination);
1301
+ return termination;
1302
+ }
1303
+
1304
+ private async abandonGrant(grant: SdkSessionGrant) {
1305
+ if (this.ownedSessionId === grant.sessionId) {
1306
+ this.ownedSessionId = undefined;
1307
+ this.grant = undefined;
1308
+ }
1309
+ const termination = this.ensureSessionTermination(grant.sessionId, "error");
1310
+ const roomCleanup = this.releaseRoom(termination.markerReady);
1311
+ await termination.markerReady;
1312
+ if (this.disposed) {
1313
+ await Promise.allSettled([roomCleanup, termination.completion]);
1314
+ return;
1315
+ }
1316
+ await Promise.allSettled([
1317
+ roomCleanup,
1318
+ this.dependencies.runtime.deactivateKeepAwake(),
1319
+ termination.completion,
1320
+ ]);
1321
+ }
1322
+
1323
+ private isOperationCurrent(generation: number) {
1324
+ return !this.disposed && generation === this.operationGeneration;
1325
+ }
1326
+
1327
+ private ensureOperation(generation: number) {
1328
+ if (!this.isOperationCurrent(generation)) throw new StaleOperationError();
1329
+ }
1330
+ }
1331
+
1332
+ export { PENDING_END_KEY };