@pickleball/expo-sdk 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +155 -101
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +14 -2
  6. package/dist/advanced.d.cts +2 -2
  7. package/dist/advanced.d.ts +2 -2
  8. package/dist/advanced.js +17 -5
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-62YV3PCD.cjs +139 -0
  12. package/dist/chunk-6F3HN6US.js +50 -0
  13. package/dist/chunk-6YE5BL3A.cjs +50 -0
  14. package/dist/chunk-7Z7TN5N3.js +318 -0
  15. package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
  16. package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
  17. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  18. package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
  19. package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
  20. package/dist/chunk-ZSKSXOCZ.js +139 -0
  21. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  22. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  23. package/dist/device-agent/index.cjs +973 -44
  24. package/dist/device-agent/index.d.cts +339 -3
  25. package/dist/device-agent/index.d.ts +339 -3
  26. package/dist/device-agent/index.js +940 -11
  27. package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
  28. package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
  29. package/dist/index.cjs +419 -937
  30. package/dist/index.d.cts +315 -32
  31. package/dist/index.d.ts +315 -32
  32. package/dist/index.js +423 -941
  33. package/dist/plugin.cjs +11 -13
  34. package/dist/plugin.d.cts +15 -1
  35. package/dist/plugin.d.ts +15 -1
  36. package/dist/plugin.js +7 -9
  37. package/dist/realtime/index.cjs +31 -0
  38. package/dist/realtime/index.d.cts +15 -0
  39. package/dist/realtime/index.d.ts +15 -0
  40. package/dist/realtime/index.js +31 -0
  41. package/dist/ui/index.cjs +1297 -0
  42. package/dist/ui/index.d.cts +493 -0
  43. package/dist/ui/index.d.ts +493 -0
  44. package/dist/ui/index.js +1297 -0
  45. package/dist/version.cjs +2 -2
  46. package/dist/version.js +1 -1
  47. package/package.json +31 -15
  48. package/src/advanced.ts +11 -9
  49. package/src/auto-resume.tsx +259 -0
  50. package/src/camera-controls.ts +99 -0
  51. package/src/camera-grant-provider.ts +392 -0
  52. package/src/camera-settings.ts +133 -0
  53. package/src/contracts.ts +73 -0
  54. package/src/device-agent/agent.ts +262 -9
  55. package/src/device-agent/beat-policy.ts +46 -0
  56. package/src/device-agent/component.tsx +11 -0
  57. package/src/device-agent/convex-beat-transport.ts +140 -0
  58. package/src/device-agent/convex-command-subscription.ts +101 -0
  59. package/src/device-agent/convex-control-channel.ts +7 -1
  60. package/src/device-agent/http-transport.ts +22 -1
  61. package/src/device-agent/index.ts +22 -0
  62. package/src/device-agent/mqtt-channel.ts +379 -0
  63. package/src/device-agent/mqtt-codec.ts +347 -0
  64. package/src/device-agent/tournament.ts +352 -0
  65. package/src/engine.ts +580 -15
  66. package/src/http-session-provider.ts +21 -189
  67. package/src/index.ts +84 -5
  68. package/src/local-video-quality.ts +43 -0
  69. package/src/native-runtime.ts +234 -78
  70. package/src/plugin.ts +19 -6
  71. package/src/provider.tsx +94 -18
  72. package/src/realtime/index.ts +56 -0
  73. package/src/resume-policy.ts +85 -0
  74. package/src/resume.ts +132 -0
  75. package/src/rtmp-room-adapter.ts +164 -12
  76. package/src/session-grant.ts +256 -0
  77. package/src/ui/camera-screen.tsx +484 -0
  78. package/src/ui/controls-sheet.tsx +445 -0
  79. package/src/ui/copy.ts +326 -0
  80. package/src/ui/helpers.ts +105 -0
  81. package/src/ui/hooks.ts +59 -0
  82. package/src/ui/index.ts +42 -0
  83. package/src/ui/preview-view.tsx +39 -0
  84. package/src/ui/primitives.tsx +304 -0
  85. package/src/uplink-check.ts +173 -0
  86. package/dist/advanced-BXDrDl1h.d.ts +0 -316
  87. package/dist/advanced-BdAa2Erv.d.cts +0 -316
  88. package/src/livekit-room-adapter.ts +0 -390
  89. package/src/livestream-screen.tsx +0 -1002
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Luật quyết định khi nối lại một phiên server còn giữ cho máy này.
3
+ *
4
+ * Chuyển từ apps/device-streamer (src/resume-policy.ts) vào SDK để app đối tác
5
+ * không phải học lại từng cái bẫy đã đo được trên fleet.
6
+ *
7
+ * Vì sao cần tách riêng: bản đầu coi MỌI thất bại là "phiên đã chết" và bỏ cuộc
8
+ * vĩnh viễn sau đúng một lần thử. Hệ quả: máy khởi động lại lúc wifi chưa kịp
9
+ * lên là mất luôn cơ hội nối lại — đúng ca hay gặp nhất, vì máy vừa mở nguồn
10
+ * thì wifi luôn lên chậm hơn app.
11
+ *
12
+ * Phân biệt hai loại thất bại là toàn bộ giá trị của module này:
13
+ * - "gone": server nói phiên không còn. Thử lại bao nhiêu cũng vô ích.
14
+ * - "retry": mạng chưa lên, server trục trặc, phản hồi méo. Phiên có thể vẫn sống.
15
+ *
16
+ * Nghi ngờ thì chọn "retry": đoán nhầm thành "gone" là mất phiên vĩnh viễn (link
17
+ * xem đổi, VOD tách đôi); đoán nhầm thành "retry" chỉ tốn vài request, và đã có
18
+ * lưới server dọn phiên mồ côi.
19
+ */
20
+
21
+ /** Phân loại phản hồi HTTP của endpoint refresh. */
22
+ export type ResumeVerdict = "ok" | "gone" | "retry";
23
+
24
+ /**
25
+ * 404 = phiên biến mất hẳn. 409 = phiên đã kết thúc (mọi namespace trả
26
+ * SESSION_CONFLICT cho thao tác trên phiên ended, và grant bị thay cũng là 409).
27
+ * 410 = Gone theo chuẩn HTTP. Mọi mã khác — kể cả 401/403 — đều là "retry":
28
+ * token có thể được cấp lại ở nhịp sau, và một lỗi xác thực tạm thời không phải
29
+ * bằng chứng phiên đã chết.
30
+ */
31
+ export function classifyResumeStatus(status: number): ResumeVerdict {
32
+ if (status === 404 || status === 409 || status === 410) return "gone";
33
+ if (status >= 200 && status < 300) return "ok";
34
+ return "retry";
35
+ }
36
+
37
+ /**
38
+ * Nhịp thử lại. Nhịp đầu 1 giây vì ca điển hình là "app mở trước khi wifi lên" —
39
+ * chờ vài giây là xong. Trần 15 giây để một máy chết hẳn không nện server mãi;
40
+ * số lần thì KHÔNG giới hạn: thà thử mãi còn hơn mất phiên, và lưới server
41
+ * (1 giờ cho live, 30 phút cho standby) mới là thứ quyết định lúc nào bỏ cuộc.
42
+ */
43
+ export const RESUME_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000];
44
+
45
+ export function resumeRetryDelayMs(attempt: number): number {
46
+ const index = Math.min(Math.max(attempt, 0), RESUME_RETRY_DELAYS_MS.length - 1);
47
+ return RESUME_RETRY_DELAYS_MS[index];
48
+ }
49
+
50
+ /**
51
+ * Bất kỳ ai định TẠO PHIÊN MỚI tự động (AutoTournament của fleet, useStandbySession
52
+ * của app đối tác) phải NHƯỜNG khi còn phiên chờ nhận lại — nhìn CẢ HAI nguồn.
53
+ *
54
+ * Bản đầu chỉ nhìn serverSession (heartbeat, về SAU vài giây); adoptableSessionId
55
+ * (dấu trên đĩa) có mặt ngay từ lúc engine khởi động xong. Khe hở vài giây đó là
56
+ * đủ để một lượt arm nổ súng trước AutoResume: hoặc đụng chốt marker (app đứng
57
+ * màn LỖI), hoặc tệ hơn — lách qua trong đúng cửa sổ race và tạo phiên chờ THỨ
58
+ * HAI đè phiên live cũ (đo thật đêm 18/08/2026).
59
+ */
60
+ export function resumeBlocksArm(input: {
61
+ adoptableSessionId: string | null;
62
+ serverSessionId: string | null;
63
+ engineSessionId: string | null;
64
+ }): boolean {
65
+ if (input.engineSessionId !== null) return false;
66
+ return input.adoptableSessionId !== null || input.serverSessionId !== null;
67
+ }
68
+
69
+ /** Quy ước externalSessionId của lượt start NHẬN LẠI — engine chỉ cho lượt này qua chốt marker. */
70
+ export function resumeExternalSessionId(sessionId: string): string {
71
+ return `resume-${sessionId}`;
72
+ }
73
+
74
+ /**
75
+ * Grant nối lại chỉ được tiêu thụ cho ĐÚNG phiên nó được cấp. AutoResume có thể
76
+ * bắn nhiều lượt (retry, đổi nguồn); không kiểm khớp thì một grant cũ còn sót có
77
+ * thể bị lượt start của phiên khác nuốt — engine nối vào nhầm phiên, đúng loại lỗi
78
+ * âm thầm khó truy nhất.
79
+ */
80
+ export function resumeGrantMatches(
81
+ externalSessionId: string | undefined,
82
+ grantSessionId: string,
83
+ ): boolean {
84
+ return externalSessionId === resumeExternalSessionId(grantSessionId);
85
+ }
package/src/resume.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type {
2
+ LivestreamSessionProvider,
3
+ SdkSessionGrant,
4
+ StartLivestreamInput,
5
+ } from "./contracts";
6
+ import { isSdkRemoteConfig } from "./contracts";
7
+ import { classifyResumeStatus, resumeGrantMatches } from "./resume-policy";
8
+ import { isFiniteNumber, isRecord, isTelemetryCredentials, type Fetch } from "./session-grant";
9
+
10
+ /**
11
+ * Nhận lại phiên sau khi app bị kill/mở lại — phần PROVIDER.
12
+ *
13
+ * Kịch bản: máy đang phát, app biến mất (sụp nguồn, OEM giết, vuốt tắt). Server
14
+ * còn giữ phiên. Lần mở sau engine thấy dấu trên đĩa (adoptableSessionId) nhưng
15
+ * KHÔNG có grant trong bộ nhớ — phải hỏi server cấp lại credentials của đúng
16
+ * phiên đó rồi start bằng grant ấy thay vì tạo phiên mới. Link xem và VOD nhờ
17
+ * vậy liền một mạch.
18
+ */
19
+
20
+ export type ResumeAttempt =
21
+ | { kind: "grant"; grant: SdkSessionGrant; live: boolean }
22
+ | { kind: "gone" }
23
+ | { kind: "retry" };
24
+
25
+ export interface ResumableSessionProvider extends LivestreamSessionProvider {
26
+ /** Đặt grant cho lượt start kế tiếp có externalSessionId `resume-<id>`. */
27
+ adoptResumeGrant(grant: SdkSessionGrant): void;
28
+ /** Hỏi server cấp lại grant cho phiên còn sống. */
29
+ resume(sessionId: string): Promise<ResumeAttempt>;
30
+ }
31
+
32
+ /**
33
+ * Bọc provider: `start()` ưu tiên grant nhận lại nếu khớp phiên, còn lại tạo phiên
34
+ * mới như thường. Grant chờ nằm trong closure (không biến module) nên hai provider
35
+ * trong cùng app không dẫm nhau.
36
+ */
37
+ export function withResume(
38
+ provider: LivestreamSessionProvider,
39
+ resume: (sessionId: string) => Promise<ResumeAttempt>,
40
+ ): ResumableSessionProvider {
41
+ let pending: SdkSessionGrant | null = null;
42
+ return {
43
+ bootstrap: provider.bootstrap.bind(provider),
44
+ refresh: provider.refresh.bind(provider),
45
+ end: provider.end.bind(provider),
46
+ publish: provider.publish?.bind(provider),
47
+ unpublish: provider.unpublish?.bind(provider),
48
+ async start(input: StartLivestreamInput) {
49
+ const grant = pending;
50
+ if (grant && resumeGrantMatches(input.externalSessionId, grant.sessionId)) {
51
+ pending = null;
52
+ return grant;
53
+ }
54
+ return provider.start(input);
55
+ },
56
+ adoptResumeGrant(grant) {
57
+ pending = grant;
58
+ },
59
+ resume,
60
+ };
61
+ }
62
+
63
+ /** Biến phản hồi refresh (đầy đủ) thành grant; méo → null để caller coi là retry. */
64
+ export function grantFromRefreshPayload(data: unknown): SdkSessionGrant | null {
65
+ if (
66
+ !isRecord(data) ||
67
+ typeof data.sessionId !== "string" ||
68
+ typeof data.serverUrl !== "string" ||
69
+ typeof data.participantToken !== "string" ||
70
+ !isFiniteNumber(data.tokenExpiresAt) ||
71
+ !isTelemetryCredentials(data.telemetry) ||
72
+ !isSdkRemoteConfig(data.config)
73
+ ) {
74
+ return null;
75
+ }
76
+ return {
77
+ sessionId: data.sessionId,
78
+ serverUrl: data.serverUrl,
79
+ participantToken: data.participantToken,
80
+ playbackUrl: typeof data.playbackUrl === "string" ? data.playbackUrl : null,
81
+ tokenExpiresAt: data.tokenExpiresAt,
82
+ telemetry: data.telemetry,
83
+ config: data.config,
84
+ };
85
+ }
86
+
87
+ export interface HttpResumeFetcherOptions {
88
+ /** URL endpoint refresh của phiên — namespace nào cũng được, miễn trả grant đầy đủ + status. */
89
+ url(sessionId: string): string;
90
+ headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
91
+ fetch?: Fetch;
92
+ timeoutMs?: number;
93
+ }
94
+
95
+ /**
96
+ * Gọi endpoint refresh để dựng lại grant. Trả về PHÂN LOẠI chứ không phải
97
+ * null-cho-mọi-thứ (xem resume-policy): mất mạng/5xx/méo = retry, 404/409/410 =
98
+ * gone. Chỉ phát tiếp khi server xác nhận phiên ĐANG live — thiếu trường status
99
+ * coi như không phát: để camera tự lên sóng nhầm còn tệ hơn là chờ một lệnh nữa.
100
+ */
101
+ export function createHttpResumeFetcher(
102
+ options: HttpResumeFetcherOptions,
103
+ ): (sessionId: string) => Promise<ResumeAttempt> {
104
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
105
+ const timeoutMs = options.timeoutMs ?? 10_000;
106
+ return async (sessionId) => {
107
+ const controller = new AbortController();
108
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
109
+ try {
110
+ const headers =
111
+ typeof options.headers === "function" ? await options.headers() : (options.headers ?? {});
112
+ const response = await fetchImplementation(options.url(sessionId), {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json", ...headers },
115
+ body: JSON.stringify({}),
116
+ signal: controller.signal,
117
+ });
118
+ const verdict = classifyResumeStatus(response.status);
119
+ if (verdict !== "ok") return { kind: verdict };
120
+ const envelope = (await response.json().catch(() => null)) as { data?: unknown } | null;
121
+ const grant = grantFromRefreshPayload(envelope?.data);
122
+ if (!grant) return { kind: "retry" };
123
+ const status = (envelope?.data as { status?: unknown }).status;
124
+ return { kind: "grant", grant, live: status === "live" };
125
+ } catch {
126
+ // fetch ném = mất mạng. Đây CHÍNH LÀ ca hay gặp nhất khi máy vừa mở nguồn.
127
+ return { kind: "retry" };
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ };
132
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  RoomConnection,
6
6
  RoomEvent,
7
7
  } from "./engine";
8
+ import { VIDEO_BITRATE_BPS } from "./uplink-check";
8
9
 
9
10
  /**
10
11
  * Trạng thái từ tầng RTMP native (trùng NativeRtmpStatus của @pickleball/rtmp-native).
@@ -14,6 +15,13 @@ export type RtmpStatus =
14
15
  | { readonly status: "disconnected"; readonly reason?: string }
15
16
  | { readonly status: "error"; readonly code: string; readonly message: string };
16
17
 
18
+ /** Phản hồi băng thông từ native (trùng NativeRtmpBandwidth của rtmp-native). */
19
+ export interface RtmpBandwidth {
20
+ /** false = encoder đang đẩy nhanh hơn đường truyền chịu được. */
21
+ readonly sufficient: boolean;
22
+ readonly videoBitrate: number;
23
+ }
24
+
17
25
  /**
18
26
  * Dấu hiệu để UI biết render nguồn nào. LiveKit trả về một track object; RTMP
19
27
  * không có "track" ở JS (khung hình đi thẳng trong native), nên trả marker này
@@ -25,7 +33,23 @@ export interface RtmpPreviewMarker {
25
33
 
26
34
  /** Bề mặt native mà adapter cần — tiêm vào để test được không cần thiết bị thật. */
27
35
  export interface NativeRtmpBindings {
28
- prepare(options: { quality: 720 | 1080; landscape: boolean }): Promise<void>;
36
+ prepare(options: {
37
+ quality: 720 | 1080;
38
+ landscape: boolean;
39
+ /** fps mong muốn (24 tiết kiệm băng thông, 30 mặc định) — thiếu = native tự chọn. */
40
+ frameRate?: 24 | 30 | 60;
41
+ /**
42
+ * Camera mở ngay từ đầu. Thiếu = native dùng camera sau mặc định. Đặt ở đây
43
+ * thay vì gọi selectCamera sau prepare để không phải mở camera hai lần.
44
+ */
45
+ cameraId?: string;
46
+ }): Promise<void>;
47
+ /**
48
+ * Xin quyền camera/mic bằng API quyền thuần (PermissionsAndroid, ...).
49
+ * Không có thì engine rơi về getUserMedia của WebRTC — thứ mở camera thật
50
+ * và đụng HaishinKit trên máy Android chỉ cho một camera mở cùng lúc.
51
+ */
52
+ requestPermissions?(): Promise<boolean>;
29
53
  /** Hướng cầm máy hiện tại — cùng nguồn mà LiveKitRoomAdapter dùng. */
30
54
  isLandscape?(): boolean;
31
55
  connect(options: {
@@ -42,6 +66,10 @@ export interface NativeRtmpBindings {
42
66
  setVideoBitrate(bitsPerSecond: number): Promise<void>;
43
67
  dispose(): Promise<void>;
44
68
  addStatusListener(listener: (event: RtmpStatus) => void): { remove(): void };
69
+ /** Optional: bản native cũ chưa phát onBandwidth thì adapter bỏ qua thích ứng. */
70
+ addBandwidthListener?(
71
+ listener: (event: RtmpBandwidth) => void,
72
+ ): { remove(): void };
45
73
  }
46
74
 
47
75
  /**
@@ -49,10 +77,50 @@ export interface NativeRtmpBindings {
49
77
  * (720p 3–6 Mbps, 1080p 6–9 Mbps). Thấp hơn khoảng này thì thể thao chuyển động
50
78
  * nhanh như pickleball bị bể khối rất rõ.
51
79
  */
52
- const VIDEO_BITRATE: Record<720 | 1080, number> = {
53
- 720: 3_500_000,
54
- 1080: 6_000_000,
55
- };
80
+ const VIDEO_BITRATE: Readonly<Record<720 | 1080, number>> = VIDEO_BITRATE_BPS;
81
+
82
+ /**
83
+ * Thích ứng bitrate theo phản hồi băng thông của native (thay cho BWE mà
84
+ * WebRTC cho không). VIDEO_BITRATE là TRẦN, không phải điều kiện: máy/mạng
85
+ * không kham nổi 6 Mbps thì hạ dần xuống tới sàn và VẪN PHÁT — thà mờ còn
86
+ * hơn đứng hình hay tràn hàng đợi rồi rớt luồng.
87
+ *
88
+ * Hạ nhanh (×0.7 mỗi tín hiệu nghẽn ~2s), hồi chậm (×1.15 sau
89
+ * RECOVER_AFTER_SUFFICIENT tín hiệu tốt liên tiếp ~10s) — mô hình AIMD.
90
+ */
91
+ export const BITRATE_FLOOR = 800_000;
92
+ export const DEGRADE_FACTOR = 0.7;
93
+ export const RECOVER_FACTOR = 1.15;
94
+ export const RECOVER_AFTER_SUFFICIENT = 5;
95
+
96
+ export interface AdaptiveBitrateState {
97
+ readonly current: number;
98
+ readonly ceiling: number;
99
+ readonly sufficientStreak: number;
100
+ }
101
+
102
+ /** Bước chuyển thuần — tách khỏi adapter để test không cần thiết bị. */
103
+ export function nextBitrateState(
104
+ state: AdaptiveBitrateState,
105
+ event: Pick<RtmpBandwidth, "sufficient">,
106
+ ): AdaptiveBitrateState {
107
+ if (!event.sufficient) {
108
+ return {
109
+ ...state,
110
+ current: Math.max(BITRATE_FLOOR, Math.round(state.current * DEGRADE_FACTOR)),
111
+ sufficientStreak: 0,
112
+ };
113
+ }
114
+ const streak = state.sufficientStreak + 1;
115
+ if (streak < RECOVER_AFTER_SUFFICIENT || state.current >= state.ceiling) {
116
+ return { ...state, sufficientStreak: Math.min(streak, RECOVER_AFTER_SUFFICIENT) };
117
+ }
118
+ return {
119
+ ...state,
120
+ current: Math.min(state.ceiling, Math.round(state.current * RECOVER_FACTOR)),
121
+ sufficientStreak: 0,
122
+ };
123
+ }
56
124
 
57
125
  /**
58
126
  * Adapter cho mode deliveryMode="device-rtmp".
@@ -65,21 +133,68 @@ const VIDEO_BITRATE: Record<720 | 1080, number> = {
65
133
  * dashboard đi đường khác (deviceControl trên Convex) — xem device-agent.
66
134
  */
67
135
  export class RtmpRoomAdapter implements RoomAdapter {
136
+ /**
137
+ * RTMP không tự hồi: native chỉ phát hiện đứt (watchdog iOS / onDisconnect
138
+ * Android) và báo "disconnected" — không tự quay số lại. Engine thấy cờ này
139
+ * là chạy vòng redial backoff. Thiếu cờ: phiên ngồi chờ "reconnected" không
140
+ * bao giờ tới rồi chết network_timeout sau 30s (prod 17/08/2026).
141
+ */
142
+ readonly needsManualReconnect = true;
143
+
68
144
  private readonly listeners = new Set<(event: RoomEvent) => void>();
69
145
  private nativeSubscription?: { remove(): void };
146
+ private bandwidthSubscription?: { remove(): void };
70
147
  private quality: 720 | 1080 = 1080;
71
148
  private connected = false;
149
+ private prepared = false;
150
+ /** Khung người dùng chọn trên UI (9:16/16:9) — ưu tiên hơn hướng cầm máy. */
151
+ private captureOrientation?: CaptureOrientation;
152
+ private bitrateState?: AdaptiveBitrateState;
153
+
154
+ /**
155
+ * Chỉ tồn tại khi bindings hỗ trợ — engine kiểm tra truthiness để quyết định
156
+ * fallback về runtime.requestPermissions() (getUserMedia), nên KHÔNG được
157
+ * khai báo method luôn-có rồi tự fallback bên trong.
158
+ */
159
+ readonly requestPermissions?: () => Promise<boolean>;
72
160
 
73
- constructor(private readonly bindings: NativeRtmpBindings) {}
161
+ constructor(
162
+ private readonly bindings: NativeRtmpBindings,
163
+ private readonly options: { frameRate?: 24 | 30 | 60; cameraId?: string } = {},
164
+ ) {
165
+ const request = bindings.requestPermissions?.bind(bindings);
166
+ if (request) this.requestPermissions = request;
167
+ }
168
+
169
+ /**
170
+ * Phần tuỳ chọn của prepare(). Giữ dạng spread có điều kiện: truyền
171
+ * `frameRate: undefined` xuống Expo Record là ghi đè mặc định của native
172
+ * bằng null chứ không phải "bỏ qua trường".
173
+ */
174
+ private prepareOverrides(): { frameRate?: 24 | 30 | 60; cameraId?: string } {
175
+ return {
176
+ ...(this.options.frameRate !== undefined
177
+ ? { frameRate: this.options.frameRate }
178
+ : {}),
179
+ ...(this.options.cameraId !== undefined
180
+ ? { cameraId: this.options.cameraId }
181
+ : {}),
182
+ };
183
+ }
74
184
 
75
185
  async preparePreview(quality: 720 | 1080): Promise<RtmpPreviewMarker> {
76
186
  this.quality = quality;
77
- // Mặc định ngang khi binding không cài: sân pickleball quay ngang chính,
78
- // đó cũng kích thước capture mặc định của HaishinKit.
187
+ // Khung người dùng chọn (9:16/16:9) nguồn sự thật; chỉ khi chưa chọn mới
188
+ // đoán theo hướng cầm máy (mặc định ngang: sân pickleball quay ngang là
189
+ // chính, và đó cũng là kích thước capture mặc định của HaishinKit).
79
190
  await this.bindings.prepare({
80
191
  quality,
81
- landscape: this.bindings.isLandscape?.() ?? true,
192
+ landscape: this.captureOrientation
193
+ ? this.captureOrientation === "landscape"
194
+ : (this.bindings.isLandscape?.() ?? true),
195
+ ...this.prepareOverrides(),
82
196
  });
197
+ this.prepared = true;
83
198
  // Khung hình do PickleballRtmpPreviewView vẽ ở tầng native; JS chỉ cần biết
84
199
  // "đã có preview và là nguồn RTMP" để chọn đúng component hiển thị.
85
200
  return { kind: "rtmp" };
@@ -106,6 +221,13 @@ export class RtmpRoomAdapter implements RoomAdapter {
106
221
  publish: input.publish !== false,
107
222
  });
108
223
  this.connected = true;
224
+ // Trần theo mức chất lượng của phiên; phản hồi băng thông sẽ điều chỉnh
225
+ // trong khoảng [BITRATE_FLOOR, trần] mà không bao giờ chặn phát.
226
+ this.bitrateState = {
227
+ current: VIDEO_BITRATE[this.quality],
228
+ ceiling: VIDEO_BITRATE[this.quality],
229
+ sufficientStreak: 0,
230
+ };
109
231
  }
110
232
 
111
233
  async setPublishing(enabled: boolean) {
@@ -115,6 +237,7 @@ export class RtmpRoomAdapter implements RoomAdapter {
115
237
  async disconnect(_stopTracks?: boolean) {
116
238
  if (!this.connected) return;
117
239
  this.connected = false;
240
+ this.bitrateState = undefined;
118
241
  await this.bindings.disconnect();
119
242
  }
120
243
 
@@ -147,9 +270,19 @@ export class RtmpRoomAdapter implements RoomAdapter {
147
270
  await this.bindings.setVideoBitrate(bitsPerSecond);
148
271
  }
149
272
 
150
- async setCaptureOrientation(_orientation: CaptureOrientation) {
151
- // Khung hình do camera quyết định; không đổi giữa luồng RTMP vì sẽ buộc
152
- // khởi động lại encoder làm thủng segment HLS.
273
+ async setCaptureOrientation(orientation: CaptureOrientation) {
274
+ this.captureOrientation = orientation;
275
+ // Đang preview (chưa phát): prepare lại để đổi khung 9:16 16:9 ngay —
276
+ // native chỉ đổi screen.frame + gắn lại camera, encoder chưa chạy.
277
+ // Đã kết nối thì KHÔNG đổi giữa luồng: buộc khởi động lại encoder và làm
278
+ // thủng segment HLS; lựa chọn sẽ áp cho lần prepare kế tiếp.
279
+ if (this.prepared && !this.connected) {
280
+ await this.bindings.prepare({
281
+ quality: this.quality,
282
+ landscape: orientation === "landscape",
283
+ ...this.prepareOverrides(),
284
+ });
285
+ }
153
286
  }
154
287
 
155
288
  subscribe(listener: (event: RoomEvent) => void) {
@@ -160,6 +293,11 @@ export class RtmpRoomAdapter implements RoomAdapter {
160
293
  if (mapped) this.emit(mapped);
161
294
  });
162
295
  }
296
+ if (!this.bandwidthSubscription && this.bindings.addBandwidthListener) {
297
+ this.bandwidthSubscription = this.bindings.addBandwidthListener((event) =>
298
+ this.handleBandwidth(event),
299
+ );
300
+ }
163
301
  return () => {
164
302
  this.listeners.delete(listener);
165
303
  };
@@ -168,11 +306,25 @@ export class RtmpRoomAdapter implements RoomAdapter {
168
306
  async dispose() {
169
307
  this.nativeSubscription?.remove();
170
308
  this.nativeSubscription = undefined;
309
+ this.bandwidthSubscription?.remove();
310
+ this.bandwidthSubscription = undefined;
171
311
  this.listeners.clear();
172
312
  this.connected = false;
313
+ this.bitrateState = undefined;
173
314
  await this.bindings.dispose();
174
315
  }
175
316
 
317
+ private handleBandwidth(event: RtmpBandwidth) {
318
+ const state = this.bitrateState;
319
+ if (!this.connected || !state) return;
320
+ const next = nextBitrateState(state, event);
321
+ this.bitrateState = next;
322
+ if (next.current !== state.current) {
323
+ // Fire-and-forget: chỉnh bitrate lỗi thì giữ mức cũ chứ không rớt luồng.
324
+ void this.bindings.setVideoBitrate(next.current).catch(() => undefined);
325
+ }
326
+ }
327
+
176
328
  private emit(event: RoomEvent) {
177
329
  for (const listener of this.listeners) listener(event);
178
330
  }