@pickleball/expo-sdk 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +155 -101
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +14 -2
- package/dist/advanced.d.cts +2 -2
- package/dist/advanced.d.ts +2 -2
- package/dist/advanced.js +17 -5
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-62YV3PCD.cjs +139 -0
- package/dist/chunk-6F3HN6US.js +50 -0
- package/dist/chunk-6YE5BL3A.cjs +50 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
- package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
- package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
- package/dist/chunk-ZSKSXOCZ.js +139 -0
- package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
- package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
- package/dist/device-agent/index.cjs +973 -44
- package/dist/device-agent/index.d.cts +339 -3
- package/dist/device-agent/index.d.ts +339 -3
- package/dist/device-agent/index.js +940 -11
- package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
- package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
- package/dist/index.cjs +419 -937
- package/dist/index.d.cts +315 -32
- package/dist/index.d.ts +315 -32
- package/dist/index.js +423 -941
- package/dist/plugin.cjs +11 -13
- package/dist/plugin.d.cts +15 -1
- package/dist/plugin.d.ts +15 -1
- package/dist/plugin.js +7 -9
- package/dist/realtime/index.cjs +31 -0
- package/dist/realtime/index.d.cts +15 -0
- package/dist/realtime/index.d.ts +15 -0
- package/dist/realtime/index.js +31 -0
- package/dist/ui/index.cjs +1297 -0
- package/dist/ui/index.d.cts +493 -0
- package/dist/ui/index.d.ts +493 -0
- package/dist/ui/index.js +1297 -0
- package/dist/version.cjs +2 -2
- package/dist/version.js +1 -1
- package/package.json +31 -15
- package/src/advanced.ts +11 -9
- package/src/auto-resume.tsx +259 -0
- package/src/camera-controls.ts +99 -0
- package/src/camera-grant-provider.ts +392 -0
- package/src/camera-settings.ts +133 -0
- package/src/contracts.ts +73 -0
- package/src/device-agent/agent.ts +262 -9
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +11 -0
- package/src/device-agent/convex-beat-transport.ts +140 -0
- package/src/device-agent/convex-command-subscription.ts +101 -0
- package/src/device-agent/convex-control-channel.ts +7 -1
- package/src/device-agent/http-transport.ts +22 -1
- package/src/device-agent/index.ts +22 -0
- package/src/device-agent/mqtt-channel.ts +379 -0
- package/src/device-agent/mqtt-codec.ts +347 -0
- package/src/device-agent/tournament.ts +352 -0
- package/src/engine.ts +580 -15
- package/src/http-session-provider.ts +21 -189
- package/src/index.ts +84 -5
- package/src/local-video-quality.ts +43 -0
- package/src/native-runtime.ts +234 -78
- package/src/plugin.ts +19 -6
- package/src/provider.tsx +94 -18
- package/src/realtime/index.ts +56 -0
- package/src/resume-policy.ts +85 -0
- package/src/resume.ts +132 -0
- package/src/rtmp-room-adapter.ts +164 -12
- package/src/session-grant.ts +256 -0
- package/src/ui/camera-screen.tsx +484 -0
- package/src/ui/controls-sheet.tsx +445 -0
- package/src/ui/copy.ts +326 -0
- package/src/ui/helpers.ts +105 -0
- package/src/ui/hooks.ts +59 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/preview-view.tsx +39 -0
- package/src/ui/primitives.tsx +304 -0
- package/src/uplink-check.ts +173 -0
- package/dist/advanced-BXDrDl1h.d.ts +0 -316
- package/dist/advanced-BdAa2Erv.d.cts +0 -316
- package/src/livekit-room-adapter.ts +0 -390
- package/src/livestream-screen.tsx +0 -1002
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import type { CameraDeviceInfo } from "../engine";
|
|
4
|
+
import { uiCopy, type UiLocale } from "./copy";
|
|
5
|
+
import { activeLensId, lensShortLabel } from "./helpers";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Khối dựng sẵn của màn hình camera / sheet cấu hình — export để app đối tác
|
|
9
|
+
* ghép thêm hàng của riêng mình vào footer sheet (Picklive Camera: "Nguồn",
|
|
10
|
+
* "Thoát kiosk") mà vẫn cùng một ngôn ngữ hình ảnh.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const uiColors = Object.freeze({
|
|
14
|
+
ink: "#0a0a0b",
|
|
15
|
+
text: "#ffffff",
|
|
16
|
+
textSecondary: "#e5e7eb",
|
|
17
|
+
textMuted: "#d1d5db",
|
|
18
|
+
textDim: "#9ca3af",
|
|
19
|
+
border: "#3f3f46",
|
|
20
|
+
panel: "rgba(15,15,19,0.93)",
|
|
21
|
+
chip: "rgba(0,0,0,0.55)",
|
|
22
|
+
chipStrong: "rgba(0,0,0,0.72)",
|
|
23
|
+
surface: "rgba(255,255,255,0.06)",
|
|
24
|
+
hairline: "rgba(255,255,255,0.08)",
|
|
25
|
+
good: "#4ade80",
|
|
26
|
+
ok: "#facc15",
|
|
27
|
+
low: "#f87171",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function StatusPill({
|
|
31
|
+
label,
|
|
32
|
+
color,
|
|
33
|
+
style,
|
|
34
|
+
}: {
|
|
35
|
+
label: string;
|
|
36
|
+
color: string;
|
|
37
|
+
style?: StyleProp<ViewStyle>;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<View style={[uiStyles.pill, { backgroundColor: color }, style]} accessibilityRole="text">
|
|
41
|
+
<Text style={uiStyles.pillText}>{label}</Text>
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ControlRow({ label, children }: { label: string; children?: ReactNode }) {
|
|
47
|
+
return (
|
|
48
|
+
<View style={uiStyles.row}>
|
|
49
|
+
<Text style={uiStyles.rowLabel}>{label}</Text>
|
|
50
|
+
{children}
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function Note({ children }: { children: ReactNode }) {
|
|
56
|
+
return <Text style={uiStyles.note}>{children}</Text>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ActionButton({
|
|
60
|
+
label,
|
|
61
|
+
onPress,
|
|
62
|
+
disabled,
|
|
63
|
+
accessibilityLabel,
|
|
64
|
+
}: {
|
|
65
|
+
label: string;
|
|
66
|
+
onPress: () => void;
|
|
67
|
+
disabled?: boolean;
|
|
68
|
+
accessibilityLabel?: string;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<Pressable
|
|
72
|
+
accessibilityRole="button"
|
|
73
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
74
|
+
disabled={disabled}
|
|
75
|
+
onPress={onPress}
|
|
76
|
+
style={[uiStyles.actionButton, disabled && uiStyles.disabled]}
|
|
77
|
+
>
|
|
78
|
+
<Text style={uiStyles.actionButtonText}>{label}</Text>
|
|
79
|
+
</Pressable>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Một dòng chọn camera — nhãn + id, vì nhãn máy thật hay trùng nhau. */
|
|
84
|
+
export function CameraOption({
|
|
85
|
+
label,
|
|
86
|
+
hint,
|
|
87
|
+
active,
|
|
88
|
+
disabled,
|
|
89
|
+
onPress,
|
|
90
|
+
}: {
|
|
91
|
+
label: string;
|
|
92
|
+
hint: string;
|
|
93
|
+
active: boolean;
|
|
94
|
+
disabled?: boolean;
|
|
95
|
+
onPress: () => void;
|
|
96
|
+
}) {
|
|
97
|
+
return (
|
|
98
|
+
<Pressable
|
|
99
|
+
accessibilityRole="button"
|
|
100
|
+
accessibilityState={{ selected: active, disabled: disabled === true }}
|
|
101
|
+
onPress={onPress}
|
|
102
|
+
disabled={disabled}
|
|
103
|
+
style={[uiStyles.cameraOption, active && uiStyles.cameraOptionActive]}
|
|
104
|
+
>
|
|
105
|
+
<Text style={[uiStyles.cameraOptionText, active && uiStyles.cameraOptionTextActive]}>
|
|
106
|
+
{label}
|
|
107
|
+
</Text>
|
|
108
|
+
<Text style={[uiStyles.cameraOptionHint, active && uiStyles.cameraOptionTextActive]}>
|
|
109
|
+
{hint}
|
|
110
|
+
</Text>
|
|
111
|
+
</Pressable>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function Segmented<T extends number | string>({
|
|
116
|
+
options,
|
|
117
|
+
value,
|
|
118
|
+
disabled,
|
|
119
|
+
onChange,
|
|
120
|
+
}: {
|
|
121
|
+
options: Array<{ value: T; label: string }>;
|
|
122
|
+
value: T;
|
|
123
|
+
disabled?: boolean;
|
|
124
|
+
onChange: (value: T) => void;
|
|
125
|
+
}) {
|
|
126
|
+
return (
|
|
127
|
+
<View style={[uiStyles.segmented, disabled && uiStyles.disabled]}>
|
|
128
|
+
{options.map((option) => {
|
|
129
|
+
const active = option.value === value;
|
|
130
|
+
return (
|
|
131
|
+
<Pressable
|
|
132
|
+
key={String(option.value)}
|
|
133
|
+
accessibilityRole="button"
|
|
134
|
+
accessibilityState={{ selected: active, disabled: disabled === true }}
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
onPress={() => onChange(option.value)}
|
|
137
|
+
style={[uiStyles.segment, active && uiStyles.segmentActive]}
|
|
138
|
+
>
|
|
139
|
+
<Text style={[uiStyles.segmentText, active && uiStyles.segmentTextActive]}>
|
|
140
|
+
{option.label}
|
|
141
|
+
</Text>
|
|
142
|
+
</Pressable>
|
|
143
|
+
);
|
|
144
|
+
})}
|
|
145
|
+
</View>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function PresetButton({
|
|
150
|
+
label,
|
|
151
|
+
active,
|
|
152
|
+
onPress,
|
|
153
|
+
}: {
|
|
154
|
+
label: string;
|
|
155
|
+
active: boolean;
|
|
156
|
+
onPress: () => void;
|
|
157
|
+
}) {
|
|
158
|
+
return (
|
|
159
|
+
<Pressable
|
|
160
|
+
accessibilityRole="button"
|
|
161
|
+
accessibilityState={{ selected: active }}
|
|
162
|
+
onPress={onPress}
|
|
163
|
+
style={[uiStyles.preset, active && uiStyles.presetActive]}
|
|
164
|
+
>
|
|
165
|
+
<Text style={[uiStyles.presetText, active && uiStyles.presetTextActive]}>{label}</Text>
|
|
166
|
+
</Pressable>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Hàng chọn ống kính: 0.5x / 1x / Tele (camera sau) + camera trước nếu có. */
|
|
171
|
+
export function LensRow({
|
|
172
|
+
cameras,
|
|
173
|
+
selectedCameraId,
|
|
174
|
+
onSelect,
|
|
175
|
+
locale = "vi",
|
|
176
|
+
}: {
|
|
177
|
+
cameras: CameraDeviceInfo[];
|
|
178
|
+
selectedCameraId: string | null;
|
|
179
|
+
onSelect: (deviceId: string) => void;
|
|
180
|
+
locale?: UiLocale;
|
|
181
|
+
}) {
|
|
182
|
+
const backLenses = cameras.filter((camera) => camera.facing === "environment");
|
|
183
|
+
const frontLens = cameras.find((camera) => camera.facing === "front");
|
|
184
|
+
const active = activeLensId(cameras, selectedCameraId);
|
|
185
|
+
if (backLenses.length === 0 && !frontLens) return null;
|
|
186
|
+
return (
|
|
187
|
+
<View style={uiStyles.lensRow}>
|
|
188
|
+
{backLenses.map((camera) => {
|
|
189
|
+
const isActive = camera.deviceId === active;
|
|
190
|
+
return (
|
|
191
|
+
<Pressable
|
|
192
|
+
key={camera.deviceId}
|
|
193
|
+
accessibilityRole="button"
|
|
194
|
+
accessibilityState={{ selected: isActive }}
|
|
195
|
+
onPress={() => onSelect(camera.deviceId)}
|
|
196
|
+
style={[uiStyles.lensButton, isActive && uiStyles.lensButtonActive]}
|
|
197
|
+
>
|
|
198
|
+
<Text style={[uiStyles.lensText, isActive && uiStyles.lensTextActive]}>
|
|
199
|
+
{lensShortLabel(camera.label)}
|
|
200
|
+
</Text>
|
|
201
|
+
</Pressable>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
{frontLens ? (
|
|
205
|
+
<Pressable
|
|
206
|
+
key={frontLens.deviceId}
|
|
207
|
+
accessibilityRole="button"
|
|
208
|
+
accessibilityState={{ selected: frontLens.deviceId === selectedCameraId }}
|
|
209
|
+
onPress={() => onSelect(frontLens.deviceId)}
|
|
210
|
+
style={[
|
|
211
|
+
uiStyles.lensButton,
|
|
212
|
+
frontLens.deviceId === selectedCameraId && uiStyles.lensButtonActive,
|
|
213
|
+
]}
|
|
214
|
+
>
|
|
215
|
+
<Text
|
|
216
|
+
style={[
|
|
217
|
+
uiStyles.lensText,
|
|
218
|
+
frontLens.deviceId === selectedCameraId && uiStyles.lensTextActive,
|
|
219
|
+
]}
|
|
220
|
+
>
|
|
221
|
+
{uiCopy[locale].lens.front}
|
|
222
|
+
</Text>
|
|
223
|
+
</Pressable>
|
|
224
|
+
) : null}
|
|
225
|
+
</View>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const uiStyles = StyleSheet.create({
|
|
230
|
+
pill: { borderRadius: 999, paddingHorizontal: 10, paddingVertical: 5 },
|
|
231
|
+
pillText: {
|
|
232
|
+
color: uiColors.text,
|
|
233
|
+
fontWeight: "800",
|
|
234
|
+
fontSize: 11,
|
|
235
|
+
letterSpacing: 0.5,
|
|
236
|
+
fontVariant: ["tabular-nums"],
|
|
237
|
+
},
|
|
238
|
+
row: {
|
|
239
|
+
flexDirection: "row",
|
|
240
|
+
alignItems: "center",
|
|
241
|
+
justifyContent: "space-between",
|
|
242
|
+
gap: 10,
|
|
243
|
+
minHeight: 32,
|
|
244
|
+
},
|
|
245
|
+
rowLabel: { color: uiColors.textSecondary, fontSize: 13, fontWeight: "600" },
|
|
246
|
+
note: { color: uiColors.textDim, fontSize: 11, lineHeight: 15 },
|
|
247
|
+
actionButton: {
|
|
248
|
+
borderRadius: 9,
|
|
249
|
+
backgroundColor: uiColors.surface,
|
|
250
|
+
borderWidth: 1,
|
|
251
|
+
borderColor: uiColors.border,
|
|
252
|
+
paddingHorizontal: 12,
|
|
253
|
+
paddingVertical: 8,
|
|
254
|
+
alignSelf: "flex-start",
|
|
255
|
+
},
|
|
256
|
+
actionButtonText: { color: uiColors.textSecondary, fontSize: 12, fontWeight: "700" },
|
|
257
|
+
cameraOption: {
|
|
258
|
+
flexDirection: "row",
|
|
259
|
+
alignItems: "center",
|
|
260
|
+
justifyContent: "space-between",
|
|
261
|
+
gap: 8,
|
|
262
|
+
backgroundColor: uiColors.surface,
|
|
263
|
+
borderRadius: 9,
|
|
264
|
+
paddingHorizontal: 12,
|
|
265
|
+
paddingVertical: 8,
|
|
266
|
+
},
|
|
267
|
+
cameraOptionActive: { backgroundColor: uiColors.text },
|
|
268
|
+
cameraOptionText: { color: uiColors.textMuted, fontSize: 12, fontWeight: "700" },
|
|
269
|
+
cameraOptionHint: { color: uiColors.textDim, fontSize: 11, flexShrink: 1 },
|
|
270
|
+
cameraOptionTextActive: { color: uiColors.ink },
|
|
271
|
+
segmented: {
|
|
272
|
+
flexDirection: "row",
|
|
273
|
+
backgroundColor: uiColors.surface,
|
|
274
|
+
borderRadius: 999,
|
|
275
|
+
padding: 3,
|
|
276
|
+
},
|
|
277
|
+
segment: { borderRadius: 999, paddingHorizontal: 13, paddingVertical: 6 },
|
|
278
|
+
segmentActive: { backgroundColor: uiColors.text },
|
|
279
|
+
segmentText: { color: uiColors.textMuted, fontSize: 12, fontWeight: "700" },
|
|
280
|
+
segmentTextActive: { color: uiColors.ink },
|
|
281
|
+
disabled: { opacity: 0.4 },
|
|
282
|
+
presetRow: { flexDirection: "row", gap: 6, flexWrap: "wrap" },
|
|
283
|
+
preset: {
|
|
284
|
+
borderRadius: 999,
|
|
285
|
+
borderWidth: 1,
|
|
286
|
+
borderColor: uiColors.border,
|
|
287
|
+
paddingHorizontal: 12,
|
|
288
|
+
paddingVertical: 6,
|
|
289
|
+
},
|
|
290
|
+
presetActive: { backgroundColor: uiColors.text, borderColor: uiColors.text },
|
|
291
|
+
presetText: { color: uiColors.textMuted, fontSize: 12, fontWeight: "700" },
|
|
292
|
+
presetTextActive: { color: uiColors.ink },
|
|
293
|
+
lensRow: {
|
|
294
|
+
flexDirection: "row",
|
|
295
|
+
gap: 8,
|
|
296
|
+
backgroundColor: uiColors.chip,
|
|
297
|
+
borderRadius: 999,
|
|
298
|
+
padding: 4,
|
|
299
|
+
},
|
|
300
|
+
lensButton: { borderRadius: 999, paddingHorizontal: 14, paddingVertical: 7 },
|
|
301
|
+
lensButtonActive: { backgroundColor: uiColors.text },
|
|
302
|
+
lensText: { color: uiColors.textSecondary, fontSize: 13, fontWeight: "700" },
|
|
303
|
+
lensTextActive: { color: uiColors.ink },
|
|
304
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Đo tốc độ TẢI LÊN — chiều duy nhất quan trọng với máy phát RTMP (root cause
|
|
3
|
+
* các ca đứng hình field day là uplink hụt). Chuyển từ apps/device-streamer/
|
|
4
|
+
* src/net-check.ts; bảng bitrate giờ là nguồn duy nhất (adapter RTMP import lại).
|
|
5
|
+
*
|
|
6
|
+
* Đích đo mặc định speed.cloudflare.com/__up (edge gần VN/SG): đo "sức uplink
|
|
7
|
+
* của đường mạng", không phải đường tới đúng VPS ingest — nghẽn thực tế nằm ở
|
|
8
|
+
* last-mile WiFi/4G nên xấp xỉ tốt, và không phải mở endpoint nào trên hạ tầng.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
declare const require: (name: string) => any;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Trần bitrate video theo mức chất lượng — trong khoảng TikTok khuyến nghị cho
|
|
15
|
+
* live (720p 3–6 Mbps, 1080p 6–9 Mbps). Thấp hơn là pickleball bể khối rất rõ.
|
|
16
|
+
*/
|
|
17
|
+
export const VIDEO_BITRATE_BPS: Readonly<Record<720 | 1080, number>> = Object.freeze({
|
|
18
|
+
720: 3_500_000,
|
|
19
|
+
1080: 6_000_000,
|
|
20
|
+
});
|
|
21
|
+
export const AUDIO_BITRATE_BPS = 128_000;
|
|
22
|
+
|
|
23
|
+
export interface UplinkCheckOptions {
|
|
24
|
+
resolution: 720 | 1080;
|
|
25
|
+
/** Đích POST khối dữ liệu; mặc định Cloudflare speed test. */
|
|
26
|
+
uploadUrl?: string;
|
|
27
|
+
/** HEAD để đo RTT (VD ingest của bạn). Bỏ trống = không đo ping. */
|
|
28
|
+
pingUrl?: string;
|
|
29
|
+
fetch?: typeof globalThis.fetch;
|
|
30
|
+
/** Đo tối thiểu chừng này thời gian sau warmup (mặc định 6s). */
|
|
31
|
+
measureMs?: number;
|
|
32
|
+
/** Trần cho cả bài đo (mặc định 25s). */
|
|
33
|
+
totalTimeoutMs?: number;
|
|
34
|
+
/** Cỡ mỗi khối POST (mặc định 2 MiB). */
|
|
35
|
+
chunkBytes?: number;
|
|
36
|
+
/** Trần cho khối warmup (mặc định 10s) — quá là mạng "low", không phải lỗi đo. */
|
|
37
|
+
warmupTimeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UplinkCheckResult {
|
|
41
|
+
uploadMbps: number;
|
|
42
|
+
/** RTT tới pingUrl, null nếu không đo/đo hỏng. */
|
|
43
|
+
pingMs: number | null;
|
|
44
|
+
/** Băng thông cần cho cấu hình (video trần + audio), Mbps. */
|
|
45
|
+
requiredMbps: number;
|
|
46
|
+
verdict: "good" | "ok" | "low";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_UPLOAD_URL = "https://speed.cloudflare.com/__up";
|
|
50
|
+
const WARMUP_BYTES = 512 * 1024;
|
|
51
|
+
|
|
52
|
+
export function requiredMbpsFor(settings: { resolution: 720 | 1080 }): number {
|
|
53
|
+
return (VIDEO_BITRATE_BPS[settings.resolution] + AUDIO_BITRATE_BPS) / 1_000_000;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verdict theo tỉ lệ đo/cần: ≥1.8× là dư (chịu được dao động), ≥1.25× tạm đủ
|
|
58
|
+
* (AIMD sẽ phải hạ bitrate lúc mạng chùng), dưới đó là thiếu — hạ cấu hình
|
|
59
|
+
* hoặc đổi mạng trước khi lên sóng.
|
|
60
|
+
*/
|
|
61
|
+
export function verdictFor(uploadMbps: number, requiredMbps: number): UplinkCheckResult["verdict"] {
|
|
62
|
+
if (uploadMbps >= requiredMbps * 1.8) return "good";
|
|
63
|
+
if (uploadMbps >= requiredMbps * 1.25) return "ok";
|
|
64
|
+
return "low";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function chunk(bytes: number): Uint8Array<ArrayBuffer> {
|
|
68
|
+
const buffer = new Uint8Array(bytes);
|
|
69
|
+
// TLS mã hoá nên nội dung không cần ngẫu nhiên thật — điền mẫu cho rẻ CPU.
|
|
70
|
+
for (let i = 0; i < bytes; i += 977) buffer[i] = i & 0xff;
|
|
71
|
+
return buffer;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* fetch của Expo (`expo/fetch`) nhận Uint8Array body — đường upload bytes đã
|
|
76
|
+
* chạy trên fleet; fetch RN mặc định không gửi được body nhị phân. Thiếu thì rơi
|
|
77
|
+
* về globalThis.fetch (test/Node).
|
|
78
|
+
*/
|
|
79
|
+
function resolveFetch(explicit?: typeof globalThis.fetch): typeof globalThis.fetch {
|
|
80
|
+
if (explicit) return explicit;
|
|
81
|
+
try {
|
|
82
|
+
const expoFetch = require("expo/fetch")?.fetch;
|
|
83
|
+
if (typeof expoFetch === "function") return expoFetch;
|
|
84
|
+
} catch {
|
|
85
|
+
// expo/fetch không có (Node) → fetch chuẩn.
|
|
86
|
+
}
|
|
87
|
+
return globalThis.fetch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runUplinkCheck(options: UplinkCheckOptions): Promise<UplinkCheckResult> {
|
|
91
|
+
const fetchImplementation = resolveFetch(options.fetch);
|
|
92
|
+
const uploadUrl = options.uploadUrl ?? DEFAULT_UPLOAD_URL;
|
|
93
|
+
const measureMs = options.measureMs ?? 6_000;
|
|
94
|
+
const totalTimeoutMs = options.totalTimeoutMs ?? 25_000;
|
|
95
|
+
const chunkBytes = options.chunkBytes ?? 2 * 1024 * 1024;
|
|
96
|
+
|
|
97
|
+
async function post(body: Uint8Array<ArrayBuffer>, timeoutMs: number): Promise<void> {
|
|
98
|
+
const abort = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => abort.abort(), timeoutMs);
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetchImplementation(uploadUrl, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
body,
|
|
104
|
+
signal: abort.signal,
|
|
105
|
+
});
|
|
106
|
+
// Đọc cạn body để kết thúc trọn request trước khi bấm giờ cú kế.
|
|
107
|
+
await response.text().catch(() => undefined);
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function measurePing(url: string): Promise<number | null> {
|
|
114
|
+
let best: number | null = null;
|
|
115
|
+
for (let i = 0; i < 3; i += 1) {
|
|
116
|
+
const abort = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => abort.abort(), 4_000);
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
await fetchImplementation(url, { method: "HEAD", signal: abort.signal });
|
|
121
|
+
const elapsed = Date.now() - started;
|
|
122
|
+
if (best === null || elapsed < best) best = elapsed;
|
|
123
|
+
} catch {
|
|
124
|
+
// bỏ lượt hỏng — ping chỉ là số phụ, không chặn kết quả
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return best;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const deadline = Date.now() + totalTimeoutMs;
|
|
133
|
+
const requiredMbps = requiredMbpsFor(options);
|
|
134
|
+
const isAbort = (error: unknown) =>
|
|
135
|
+
(error as { name?: unknown } | null)?.name === "AbortError";
|
|
136
|
+
|
|
137
|
+
// Warmup nhỏ không tính giờ: nuốt TCP slow-start + bắt tay TLS. Quá 10s cho
|
|
138
|
+
// 512 KiB (< ~0,4 Mbps) là mạng KHÔNG ĐỦ — đó là kết quả đo, không phải lỗi
|
|
139
|
+
// đo: trả verdict "low" thay vì ném để UI hiện đúng cảnh báo.
|
|
140
|
+
const warmupBytes = Math.min(WARMUP_BYTES, chunkBytes);
|
|
141
|
+
const warmupStarted = Date.now();
|
|
142
|
+
try {
|
|
143
|
+
await post(chunk(warmupBytes), options.warmupTimeoutMs ?? 10_000);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!isAbort(error)) throw error;
|
|
146
|
+
const seconds = Math.max(1, (Date.now() - warmupStarted) / 1000);
|
|
147
|
+
const uploadMbps = (warmupBytes * 8) / seconds / 1_000_000;
|
|
148
|
+
return { uploadMbps, pingMs: null, requiredMbps, verdict: "low" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const body = chunk(chunkBytes);
|
|
152
|
+
let sentBytes = 0;
|
|
153
|
+
const started = Date.now();
|
|
154
|
+
while (Date.now() - started < measureMs && Date.now() < deadline) {
|
|
155
|
+
try {
|
|
156
|
+
await post(body, Math.max(3_000, deadline - Date.now()));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// Hết trần thời gian giữa chừng: tính theo phần đã gửi trọn (đường mạng
|
|
159
|
+
// yếu tới mức này thì verdict chắc chắn là "low").
|
|
160
|
+
if (!isAbort(error)) throw error;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
sentBytes += body.byteLength;
|
|
164
|
+
}
|
|
165
|
+
const elapsedSeconds = Math.max((Date.now() - started) / 1000, 0.001);
|
|
166
|
+
const uploadMbps = (sentBytes * 8) / elapsedSeconds / 1_000_000;
|
|
167
|
+
return {
|
|
168
|
+
uploadMbps,
|
|
169
|
+
pingMs: options.pingUrl ? await measurePing(options.pingUrl) : null,
|
|
170
|
+
requiredMbps,
|
|
171
|
+
verdict: verdictFor(uploadMbps, requiredMbps),
|
|
172
|
+
};
|
|
173
|
+
}
|