@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,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
+ }