@pickleball/expo-sdk 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +163 -40
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +47 -0
- package/dist/advanced.d.cts +3 -0
- package/dist/advanced.d.ts +3 -0
- package/dist/advanced.js +47 -0
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-2JHULJZ7.js +265 -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-7OX2DSKI.cjs +33 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/chunk-DHDX3ZOO.js +800 -0
- package/dist/chunk-GXQYMPHE.cjs +9 -0
- package/dist/chunk-IUIRVKJI.cjs +2290 -0
- package/dist/chunk-KFQGP6VL.js +33 -0
- package/dist/chunk-LCWGNGEN.cjs +265 -0
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/chunk-XGP2MN5R.js +2290 -0
- package/dist/chunk-XL4CISVQ.js +9 -0
- package/dist/chunk-YGSQ25UA.cjs +800 -0
- 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 +1427 -0
- package/dist/device-agent/index.d.cts +493 -0
- package/dist/device-agent/index.d.ts +493 -0
- package/dist/device-agent/index.js +1427 -0
- package/dist/engine-BSvSdCSE.d.cts +741 -0
- package/dist/engine-BSvSdCSE.d.ts +741 -0
- package/dist/index.cjs +426 -6174
- package/dist/index.d.cts +299 -1400
- package/dist/index.d.ts +299 -1400
- package/dist/index.js +413 -6063
- package/dist/match/index.cjs +2456 -0
- package/dist/match/index.d.cts +253 -0
- package/dist/match/index.d.ts +253 -0
- package/dist/match/index.js +2456 -0
- package/dist/match/remote/index.cjs +21 -0
- package/dist/match/remote/index.d.cts +67 -0
- package/dist/match/remote/index.d.ts +67 -0
- package/dist/match/remote/index.js +21 -0
- package/dist/match/voice/index.cjs +23 -0
- package/dist/match/voice/index.d.cts +114 -0
- package/dist/match/voice/index.d.ts +114 -0
- package/dist/match/voice/index.js +23 -0
- package/dist/plugin.cjs +52 -71
- package/dist/plugin.d.cts +28 -1
- package/dist/plugin.d.ts +28 -1
- package/dist/plugin.js +26 -23
- 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/types-CrmcX408.d.cts +176 -0
- package/dist/types-CrmcX408.d.ts +176 -0
- package/dist/types-DiKWjwvN.d.cts +96 -0
- package/dist/types-DiKWjwvN.d.ts +96 -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 +4 -32
- package/dist/version.js +2 -1
- package/package.json +95 -15
- package/src/advanced.ts +49 -0
- 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 +696 -0
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +60 -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 +88 -0
- package/src/device-agent/http-transport.ts +164 -0
- package/src/device-agent/index.ts +26 -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 +601 -20
- package/src/errors.ts +16 -0
- package/src/http-session-provider.ts +21 -183
- package/src/index.ts +117 -14
- package/src/local-video-quality.ts +43 -0
- package/src/match/index.ts +29 -0
- package/src/match/provider.tsx +4 -2
- package/src/match/remote/hid-gesture.ts +10 -2
- package/src/match/remote/types.ts +6 -4
- package/src/match/voice/scheduler.ts +6 -1
- package/src/match/voice/service.ts +16 -4
- package/src/native-runtime.ts +283 -77
- package/src/plugin.ts +54 -20
- package/src/provider.tsx +129 -22
- 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 +348 -0
- 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/chunk-VHLUJQG5.js +0 -17
- package/src/livekit-room-adapter.ts +0 -353
- package/src/livestream-screen.tsx +0 -980
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CameraDeviceInfo,
|
|
3
|
+
CaptureOrientation,
|
|
4
|
+
RoomAdapter,
|
|
5
|
+
RoomConnection,
|
|
6
|
+
RoomEvent,
|
|
7
|
+
} from "./engine";
|
|
8
|
+
import { VIDEO_BITRATE_BPS } from "./uplink-check";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Trạng thái từ tầng RTMP native (trùng NativeRtmpStatus của @pickleball/rtmp-native).
|
|
12
|
+
*/
|
|
13
|
+
export type RtmpStatus =
|
|
14
|
+
| { readonly status: "idle" | "connecting" | "connected" | "publishing" }
|
|
15
|
+
| { readonly status: "disconnected"; readonly reason?: string }
|
|
16
|
+
| { readonly status: "error"; readonly code: string; readonly message: string };
|
|
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
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Dấu hiệu để UI biết render nguồn nào. LiveKit trả về một track object; RTMP
|
|
27
|
+
* không có "track" ở JS (khung hình đi thẳng trong native), nên trả marker này
|
|
28
|
+
* — nếu trả undefined thì UI tưởng chưa có preview và vẽ màn TỐI ĐEN.
|
|
29
|
+
*/
|
|
30
|
+
export interface RtmpPreviewMarker {
|
|
31
|
+
readonly kind: "rtmp";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bề mặt native mà adapter cần — tiêm vào để test được không cần thiết bị thật. */
|
|
35
|
+
export interface NativeRtmpBindings {
|
|
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>;
|
|
53
|
+
/** Hướng cầm máy hiện tại — cùng nguồn mà LiveKitRoomAdapter dùng. */
|
|
54
|
+
isLandscape?(): boolean;
|
|
55
|
+
connect(options: {
|
|
56
|
+
url: string;
|
|
57
|
+
videoBitrate: number;
|
|
58
|
+
publish: boolean;
|
|
59
|
+
}): Promise<void>;
|
|
60
|
+
setPublishing(enabled: boolean): Promise<void>;
|
|
61
|
+
disconnect(): Promise<void>;
|
|
62
|
+
switchCamera(): Promise<void>;
|
|
63
|
+
listCameras(): Array<{ deviceId: string; label: string; facing: string }>;
|
|
64
|
+
selectCamera(deviceId: string, facing: string): Promise<void>;
|
|
65
|
+
setMicrophoneEnabled(enabled: boolean): Promise<void>;
|
|
66
|
+
setVideoBitrate(bitsPerSecond: number): Promise<void>;
|
|
67
|
+
dispose(): Promise<void>;
|
|
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 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Bitrate theo mức chất lượng — nằm trong khoảng TikTok khuyến nghị cho live
|
|
77
|
+
* (720p 3–6 Mbps, 1080p 6–9 Mbps). Thấp hơn khoảng này thì thể thao chuyển động
|
|
78
|
+
* nhanh như pickleball bị bể khối rất rõ.
|
|
79
|
+
*/
|
|
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
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Adapter cho mode deliveryMode="device-rtmp".
|
|
127
|
+
*
|
|
128
|
+
* Thiết bị mã hoá H.264 bằng phần cứng MỘT lần rồi đẩy thẳng RTMP; server chỉ
|
|
129
|
+
* remux sang HLS. Không có phòng LiveKit, không egress → $0 transcode.
|
|
130
|
+
*
|
|
131
|
+
* Khác LiveKitRoomAdapter ở một điểm quan trọng: RTMP **không có kênh dữ liệu**,
|
|
132
|
+
* nên adapter này không bao giờ phát RoomEvent "command". Lệnh điều khiển từ
|
|
133
|
+
* dashboard đi đường khác (deviceControl trên Convex) — xem device-agent.
|
|
134
|
+
*/
|
|
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
|
+
|
|
144
|
+
private readonly listeners = new Set<(event: RoomEvent) => void>();
|
|
145
|
+
private nativeSubscription?: { remove(): void };
|
|
146
|
+
private bandwidthSubscription?: { remove(): void };
|
|
147
|
+
private quality: 720 | 1080 = 1080;
|
|
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>;
|
|
160
|
+
|
|
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
|
+
}
|
|
184
|
+
|
|
185
|
+
async preparePreview(quality: 720 | 1080): Promise<RtmpPreviewMarker> {
|
|
186
|
+
this.quality = quality;
|
|
187
|
+
// Khung người dùng chọn (9:16/16:9) là 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).
|
|
190
|
+
await this.bindings.prepare({
|
|
191
|
+
quality,
|
|
192
|
+
landscape: this.captureOrientation
|
|
193
|
+
? this.captureOrientation === "landscape"
|
|
194
|
+
: (this.bindings.isLandscape?.() ?? true),
|
|
195
|
+
...this.prepareOverrides(),
|
|
196
|
+
});
|
|
197
|
+
this.prepared = true;
|
|
198
|
+
// Khung hình do PickleballRtmpPreviewView vẽ ở tầng native; JS chỉ cần biết
|
|
199
|
+
// "đã có preview và là nguồn RTMP" để chọn đúng component hiển thị.
|
|
200
|
+
return { kind: "rtmp" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async connect(input: RoomConnection) {
|
|
204
|
+
// Chặn lệch mode: nếu app chọn adapter RTMP nhưng phiên phía server lại là
|
|
205
|
+
// LiveKit thì serverUrl sẽ là wss://. Không kiểm ở đây thì HaishinKit nhận
|
|
206
|
+
// URL vô nghĩa và hỏng câm lặng, rất khó truy ngược.
|
|
207
|
+
if (!/^rtmps?:\/\//i.test(input.serverUrl)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Mode device-rtmp cần endpoint rtmp:// nhưng phiên trả về "${input.serverUrl}". ` +
|
|
210
|
+
"Kiểm tra deliveryMode của app trên server (partnerApps.deliveryMode).",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
// Backend cấp rtmpsUrl kết thúc bằng "/" và rtmpsStreamKey riêng; ghép lại
|
|
214
|
+
// thành URL publish đầy đủ. Stream key đóng vai trò bearer token của phiên.
|
|
215
|
+
const url = `${input.serverUrl}${input.participantToken}`;
|
|
216
|
+
await this.bindings.setMicrophoneEnabled(input.microphoneEnabled);
|
|
217
|
+
await this.bindings.connect({
|
|
218
|
+
url,
|
|
219
|
+
videoBitrate: VIDEO_BITRATE[this.quality],
|
|
220
|
+
// publish !== false → mặc định phát ngay; false = vào standby.
|
|
221
|
+
publish: input.publish !== false,
|
|
222
|
+
});
|
|
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
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async setPublishing(enabled: boolean) {
|
|
234
|
+
await this.bindings.setPublishing(enabled);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async disconnect(_stopTracks?: boolean) {
|
|
238
|
+
if (!this.connected) return;
|
|
239
|
+
this.connected = false;
|
|
240
|
+
this.bitrateState = undefined;
|
|
241
|
+
await this.bindings.disconnect();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async switchCamera() {
|
|
245
|
+
await this.bindings.switchCamera();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Ống kính vật lý (0.5x siêu rộng · 1x · tele) — không có thì UI kẹt ở mặc định. */
|
|
249
|
+
async listCameras(): Promise<CameraDeviceInfo[]> {
|
|
250
|
+
return this.bindings.listCameras().map((camera) => ({
|
|
251
|
+
deviceId: camera.deviceId,
|
|
252
|
+
label: camera.label,
|
|
253
|
+
facing:
|
|
254
|
+
camera.facing === "front" || camera.facing === "environment"
|
|
255
|
+
? camera.facing
|
|
256
|
+
: "unknown",
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async selectCamera(deviceId: string, facing: CameraDeviceInfo["facing"]) {
|
|
261
|
+
await this.bindings.selectCamera(deviceId, facing);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async setMicrophoneEnabled(enabled: boolean) {
|
|
265
|
+
await this.bindings.setMicrophoneEnabled(enabled);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Hạ chất lượng khi mạng yếu — thay cho BWE mà WebRTC cho sẵn. */
|
|
269
|
+
async setVideoBitrate(bitsPerSecond: number) {
|
|
270
|
+
await this.bindings.setVideoBitrate(bitsPerSecond);
|
|
271
|
+
}
|
|
272
|
+
|
|
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
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
subscribe(listener: (event: RoomEvent) => void) {
|
|
289
|
+
this.listeners.add(listener);
|
|
290
|
+
if (!this.nativeSubscription) {
|
|
291
|
+
this.nativeSubscription = this.bindings.addStatusListener((event) => {
|
|
292
|
+
const mapped = mapStatus(event);
|
|
293
|
+
if (mapped) this.emit(mapped);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (!this.bandwidthSubscription && this.bindings.addBandwidthListener) {
|
|
297
|
+
this.bandwidthSubscription = this.bindings.addBandwidthListener((event) =>
|
|
298
|
+
this.handleBandwidth(event),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return () => {
|
|
302
|
+
this.listeners.delete(listener);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async dispose() {
|
|
307
|
+
this.nativeSubscription?.remove();
|
|
308
|
+
this.nativeSubscription = undefined;
|
|
309
|
+
this.bandwidthSubscription?.remove();
|
|
310
|
+
this.bandwidthSubscription = undefined;
|
|
311
|
+
this.listeners.clear();
|
|
312
|
+
this.connected = false;
|
|
313
|
+
this.bitrateState = undefined;
|
|
314
|
+
await this.bindings.dispose();
|
|
315
|
+
}
|
|
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
|
+
|
|
328
|
+
private emit(event: RoomEvent) {
|
|
329
|
+
for (const listener of this.listeners) listener(event);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Trạng thái native → RoomEvent của engine. `null` = không cần báo lên. */
|
|
334
|
+
export function mapStatus(event: RtmpStatus): RoomEvent | null {
|
|
335
|
+
switch (event.status) {
|
|
336
|
+
case "error":
|
|
337
|
+
return { type: "media-error", error: new Error(event.message) };
|
|
338
|
+
case "disconnected":
|
|
339
|
+
return { type: "disconnected", reason: event.reason };
|
|
340
|
+
case "connecting":
|
|
341
|
+
return { type: "reconnecting" };
|
|
342
|
+
case "publishing":
|
|
343
|
+
return { type: "reconnected" };
|
|
344
|
+
// "idle"/"connected" là bước trung gian, engine không cần biết.
|
|
345
|
+
default:
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CameraGrant,
|
|
3
|
+
SdkBootstrap,
|
|
4
|
+
SdkRemoteConfig,
|
|
5
|
+
SdkSessionGrant,
|
|
6
|
+
SdkSessionRefresh,
|
|
7
|
+
SdkTelemetryCredentials,
|
|
8
|
+
} from "./contracts";
|
|
9
|
+
import { isSdkRemoteConfig } from "./contracts";
|
|
10
|
+
import {
|
|
11
|
+
ExpoSdkConfigurationError,
|
|
12
|
+
ExpoSdkTimeoutError,
|
|
13
|
+
InvalidResponseError,
|
|
14
|
+
NetworkUnavailableError,
|
|
15
|
+
PickleballExpoError,
|
|
16
|
+
errorFromCode,
|
|
17
|
+
} from "./errors";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validator + helper HTTP dùng chung cho hai session provider:
|
|
21
|
+
* - createHttpSessionProvider (proxy đối tác / first-party device-app)
|
|
22
|
+
* - createCameraGrantProvider (điện thoại đối tác, grant từ backend đối tác)
|
|
23
|
+
*
|
|
24
|
+
* Tách ra để hai provider không chép luật "grant thế nào là hợp lệ" ra hai nơi
|
|
25
|
+
* rồi lệch nhau; engine chỉ tin `isSdkRemoteConfig`, còn hình dạng grant là
|
|
26
|
+
* việc của lớp này.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type Fetch = typeof globalThis.fetch;
|
|
30
|
+
|
|
31
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isFiniteNumber(value: unknown): value is number {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isBootstrap(value: unknown): value is SdkBootstrap {
|
|
40
|
+
return (
|
|
41
|
+
isRecord(value) &&
|
|
42
|
+
isSdkRemoteConfig(value.config) &&
|
|
43
|
+
isFiniteNumber(value.serverTime) &&
|
|
44
|
+
typeof value.rolloutEnabled === "boolean" &&
|
|
45
|
+
(value.message === undefined || typeof value.message === "string")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isTelemetryCredentials(value: unknown): value is SdkTelemetryCredentials {
|
|
50
|
+
return (
|
|
51
|
+
isRecord(value) &&
|
|
52
|
+
typeof value.endpoint === "string" &&
|
|
53
|
+
/^https:\/\//.test(value.endpoint) &&
|
|
54
|
+
typeof value.token === "string" &&
|
|
55
|
+
value.token.length > 0 &&
|
|
56
|
+
isFiniteNumber(value.expiresAt)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Grant phiên. SDK 1.0 là RTMP-only: `serverUrl` phải là rtmp(s):// (ingest).
|
|
62
|
+
* Grant LiveKit (wss://) của mô hình cũ bị từ chối ngay ở đây — lỗi
|
|
63
|
+
* INVALID_RESPONSE rõ ràng thay vì HaishinKit ném lúc connect.
|
|
64
|
+
*/
|
|
65
|
+
export function isSessionGrant(value: unknown): value is SdkSessionGrant {
|
|
66
|
+
return (
|
|
67
|
+
isRecord(value) &&
|
|
68
|
+
typeof value.sessionId === "string" &&
|
|
69
|
+
value.sessionId.length > 0 &&
|
|
70
|
+
typeof value.serverUrl === "string" &&
|
|
71
|
+
/^rtmps?:\/\//i.test(value.serverUrl) &&
|
|
72
|
+
typeof value.participantToken === "string" &&
|
|
73
|
+
value.participantToken.length > 0 &&
|
|
74
|
+
(value.playbackUrl === null || typeof value.playbackUrl === "string") &&
|
|
75
|
+
isFiniteNumber(value.tokenExpiresAt) &&
|
|
76
|
+
isTelemetryCredentials(value.telemetry) &&
|
|
77
|
+
isSdkRemoteConfig(value.config)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function optionalNullableString(value: unknown): boolean {
|
|
82
|
+
return value === undefined || value === null || typeof value === "string";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Camera grant (điện thoại đối tác): superset của SdkSessionGrant, BẮT BUỘC là
|
|
87
|
+
* RTMP và mang `status` để đường nhận lại biết phiên cũ đang phát hay đang chờ.
|
|
88
|
+
*/
|
|
89
|
+
export function isCameraGrant(value: unknown): value is CameraGrant {
|
|
90
|
+
return (
|
|
91
|
+
isSessionGrant(value) &&
|
|
92
|
+
/^rtmps?:\/\//i.test(value.serverUrl) &&
|
|
93
|
+
((value as { status?: unknown }).status === "scheduled" ||
|
|
94
|
+
(value as { status?: unknown }).status === "live") &&
|
|
95
|
+
optionalNullableString((value as { matchRef?: unknown }).matchRef) &&
|
|
96
|
+
optionalNullableString((value as { courtRef?: unknown }).courtRef) &&
|
|
97
|
+
optionalNullableString((value as { watchUrl?: unknown }).watchUrl) &&
|
|
98
|
+
optionalNullableString((value as { convexUrl?: unknown }).convexUrl)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isSessionRefresh(value: unknown): value is SdkSessionRefresh {
|
|
103
|
+
if (!isRecord(value) || !isSdkRemoteConfig(value.config)) return false;
|
|
104
|
+
if (
|
|
105
|
+
value.participantToken !== undefined &&
|
|
106
|
+
(typeof value.participantToken !== "string" || !value.participantToken)
|
|
107
|
+
) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (value.tokenExpiresAt !== undefined && !isFiniteNumber(value.tokenExpiresAt)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return value.telemetry === undefined || isTelemetryCredentials(value.telemetry);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isSessionStatus(
|
|
117
|
+
value: unknown,
|
|
118
|
+
): value is { sessionId: string; status: string } {
|
|
119
|
+
return (
|
|
120
|
+
isRecord(value) &&
|
|
121
|
+
typeof value.sessionId === "string" &&
|
|
122
|
+
value.sessionId.length > 0 &&
|
|
123
|
+
typeof value.status === "string" &&
|
|
124
|
+
value.status.length > 0
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Gốc API của phiên suy từ endpoint telemetry trong grant: `…/sessions/{id}/telemetry`
|
|
130
|
+
* → `…/sessions/{id}`. Đây là cách duy nhất SDK biết namespace (sdk / camera /
|
|
131
|
+
* device-app) mà không cần app cấu hình URL PickLive nào.
|
|
132
|
+
*/
|
|
133
|
+
export function sessionApiBaseFromTelemetryEndpoint(endpoint: string): string {
|
|
134
|
+
if (!/^https:\/\//.test(endpoint) || !endpoint.endsWith("/telemetry")) {
|
|
135
|
+
throw new InvalidResponseError("The telemetry endpoint must be an HTTPS …/telemetry URL");
|
|
136
|
+
}
|
|
137
|
+
return endpoint.slice(0, -"/telemetry".length);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
declare const __DEV__: boolean | undefined;
|
|
141
|
+
|
|
142
|
+
// RFC 1918 private ranges: 10/8, 172.16/12, 192.168/16.
|
|
143
|
+
const PRIVATE_LAN_HOST =
|
|
144
|
+
/^(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})$/;
|
|
145
|
+
|
|
146
|
+
export function normalizeBaseUrl(value: string): string {
|
|
147
|
+
try {
|
|
148
|
+
const url = new URL(value);
|
|
149
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
150
|
+
const secure = url.protocol === "https:";
|
|
151
|
+
const localDevelopment = url.protocol === "http:" && loopbackHosts.has(url.hostname);
|
|
152
|
+
// Dev bundle only (Metro/dev client): a device on the same network must
|
|
153
|
+
// reach the dev proxy over plain HTTP at a private LAN address. Release
|
|
154
|
+
// bundles compile __DEV__ to false, so production keeps the HTTPS rule.
|
|
155
|
+
const devPrivateLan =
|
|
156
|
+
typeof __DEV__ !== "undefined" &&
|
|
157
|
+
__DEV__ === true &&
|
|
158
|
+
url.protocol === "http:" &&
|
|
159
|
+
PRIVATE_LAN_HOST.test(url.hostname);
|
|
160
|
+
if ((!secure && !localDevelopment && !devPrivateLan) || url.username || url.password) {
|
|
161
|
+
throw new Error();
|
|
162
|
+
}
|
|
163
|
+
return value.replace(/\/+$/, "");
|
|
164
|
+
} catch {
|
|
165
|
+
throw new ExpoSdkConfigurationError(
|
|
166
|
+
"baseUrl must use HTTPS (HTTP is allowed only for localhost loopback)",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function joinUrl(base: string, path: string) {
|
|
172
|
+
return `${base}/${path.replace(/^\/+/, "")}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Lỗi từ envelope `{error:{code,message}}` + mã HTTP. Mã HTTP đi kèm vì envelope
|
|
177
|
+
* không phân biệt được "phiên đã biến mất" (404) với "server trục trặc" (5xx) —
|
|
178
|
+
* engine cần 404/409 để quyết dừng quay số hay thử lại.
|
|
179
|
+
*/
|
|
180
|
+
export function parseEnvelopeError(
|
|
181
|
+
envelope: unknown,
|
|
182
|
+
httpStatus: number,
|
|
183
|
+
): PickleballExpoError & { httpStatus: number; reason?: string; retryAfterMs?: number } {
|
|
184
|
+
const body = isRecord(envelope) && isRecord(envelope.error) ? envelope.error : null;
|
|
185
|
+
const error =
|
|
186
|
+
body && typeof body.code === "string"
|
|
187
|
+
? errorFromCode(body.code, typeof body.message === "string" ? body.message : undefined)
|
|
188
|
+
: new InvalidResponseError();
|
|
189
|
+
// `reason` (409: superseded|ended|expired) và `retryAfterMs` (429) đi kèm để
|
|
190
|
+
// app phân biệt ba ca 409 mà không phải đọc chuỗi message.
|
|
191
|
+
return Object.assign(
|
|
192
|
+
error,
|
|
193
|
+
{ httpStatus },
|
|
194
|
+
body && typeof body.reason === "string" ? { reason: body.reason } : {},
|
|
195
|
+
body && typeof body.retryAfterMs === "number" ? { retryAfterMs: body.retryAfterMs } : {},
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface JsonPosterOptions {
|
|
200
|
+
fetch?: Fetch;
|
|
201
|
+
timeoutMs?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* POST JSON → mở bọc `{data}`; lỗi mạng/timeout/envelope map thành lỗi SDK
|
|
206
|
+
* (giữ `httpStatus`). Dùng chung cho mọi provider.
|
|
207
|
+
*/
|
|
208
|
+
export function createJsonPoster(options: JsonPosterOptions = {}) {
|
|
209
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
210
|
+
if (typeof fetchImplementation !== "function") {
|
|
211
|
+
throw new ExpoSdkConfigurationError("A Fetch API implementation is required");
|
|
212
|
+
}
|
|
213
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
214
|
+
return async function post(
|
|
215
|
+
url: string,
|
|
216
|
+
body: unknown,
|
|
217
|
+
headers: Record<string, string> = {},
|
|
218
|
+
): Promise<unknown> {
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
221
|
+
try {
|
|
222
|
+
const response = await fetchImplementation(url, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
225
|
+
body: JSON.stringify(body),
|
|
226
|
+
signal: controller.signal,
|
|
227
|
+
redirect: "error",
|
|
228
|
+
});
|
|
229
|
+
const envelope = await response.json().catch(() => {
|
|
230
|
+
throw new InvalidResponseError();
|
|
231
|
+
});
|
|
232
|
+
if (!response.ok) throw parseEnvelopeError(envelope, response.status);
|
|
233
|
+
if (!isRecord(envelope) || !("data" in envelope)) {
|
|
234
|
+
throw new InvalidResponseError();
|
|
235
|
+
}
|
|
236
|
+
return envelope.data;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (controller.signal.aborted) throw new ExpoSdkTimeoutError();
|
|
239
|
+
if (error instanceof PickleballExpoError) throw error;
|
|
240
|
+
if (error instanceof TypeError) {
|
|
241
|
+
throw new NetworkUnavailableError(undefined, { cause: error });
|
|
242
|
+
}
|
|
243
|
+
throw error;
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(timeout);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Mã HTTP gắn trên lỗi provider, nếu có. */
|
|
251
|
+
export function httpStatusOf(error: unknown): number | undefined {
|
|
252
|
+
const status = (error as { httpStatus?: unknown } | null)?.httpStatus;
|
|
253
|
+
return typeof status === "number" ? status : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type { SdkRemoteConfig };
|