@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
@@ -1,1002 +0,0 @@
1
- import React, { useEffect, useState, type ReactNode } from "react";
2
- import {
3
- Pressable,
4
- ScrollView,
5
- Text,
6
- View,
7
- useWindowDimensions,
8
- type ViewStyle,
9
- } from "react-native";
10
- import { useSafeAreaInsets } from "react-native-safe-area-context";
11
- import { sessionShortCode } from "@pickleball/shared";
12
- import type { LivestreamState, StartLivestreamInput } from "./contracts";
13
- import type { ExpoSdkErrorCode } from "./errors";
14
- import type { LocalPreviewReference } from "./livekit-room-adapter";
15
- import { useLivestream } from "./provider";
16
-
17
- declare const require: (name: string) => any;
18
-
19
- type RenderState = ReturnType<typeof useLivestream>;
20
-
21
- export interface LivestreamScreenProps {
22
- input: StartLivestreamInput;
23
- style?: ViewStyle;
24
- /**
25
- * Hướng máy vật lý (từ cảm biến, ví dụ expo-sensors DeviceMotion). Khi được
26
- * cung cấp, nút bắt đầu phát bị chặn nếu máy đang cầm lệch với khung
27
- * 9:16/16:9 đã chọn. Bỏ trống để tắt kiểm tra.
28
- */
29
- physicalOrientation?: "portrait" | "landscape" | null;
30
- /**
31
- * Tự kết nối vào phòng khi mở màn hình nếu consent đã được lưu từ trước
32
- * (mặc định bật) — dashboard điều khiển được ngay từ lúc app bật.
33
- */
34
- autoConnect?: boolean;
35
- renderPreview?: (state: RenderState) => ReactNode;
36
- renderHeader?: (state: RenderState) => ReactNode;
37
- renderControls?: (state: RenderState) => ReactNode;
38
- renderError?: (state: RenderState) => ReactNode;
39
- onEnded?: () => void;
40
- }
41
-
42
- const copy = {
43
- vi: {
44
- states: {
45
- idle: "Sẵn sàng",
46
- preparing: "Đang chuẩn bị…",
47
- preview: "Xem trước",
48
- connecting: "Đang kết nối…",
49
- standby: "SẴN SÀNG PHÁT",
50
- live: "ĐANG PHÁT",
51
- reconnecting: "Đang kết nối lại…",
52
- ending: "Đang kết thúc…",
53
- ended: "Đã kết thúc",
54
- error: "Đã xảy ra lỗi",
55
- },
56
- consent: "Đồng ý ghi hình",
57
- consentDetail: "Tôi đồng ý phiên phát này được ghi hình và lưu thành video.",
58
- start: "Bắt đầu kết nối",
59
- startStandby: "Chế độ chờ",
60
- startLive: "Phát ngay",
61
- confirmStart: "Xác nhận bắt đầu",
62
- afterThree: "Kết nối sau 3 giây",
63
- live: "ĐANG PHÁT",
64
- endLabel: "Kết thúc phát",
65
- endQuestion: "Kết thúc phiên phát?",
66
- end: "Kết thúc",
67
- cancel: "Hủy",
68
- ended: "Đã kết thúc",
69
- retry: "Thử lại",
70
- errorDetail: "Chi tiết kỹ thuật",
71
- offline: "Mất kết nối mạng",
72
- flip: "Đổi camera",
73
- playLabel: "Bắt đầu phát",
74
- pauseLabel: "Tạm dừng phát",
75
- mic: "Micrô",
76
- orientationLabel: "Khung hình",
77
- portrait: "9:16 Dọc",
78
- landscape: "16:9 Ngang",
79
- portraitHint: "Cầm máy dọc trong suốt buổi quay.",
80
- landscapeHint: "Cầm máy ngang trong suốt buổi quay.",
81
- rotatePortrait: "Xoay máy dọc theo khung 9:16 để bắt đầu phát.",
82
- rotateLandscape: "Xoay máy ngang theo khung 16:9 để bắt đầu phát.",
83
- lensLabel: "Ống kính",
84
- cameraLabel: "Camera",
85
- cameraFront: "Trước",
86
- cameraBack: "Sau",
87
- visibilityLabel: "Chế độ",
88
- visPublic: "Công khai",
89
- visPrivate: "Riêng tư",
90
- backToPreview: "Quay lại màn hình chờ",
91
- },
92
- en: {
93
- states: {
94
- idle: "Ready",
95
- preparing: "Preparing…",
96
- preview: "Preview",
97
- connecting: "Connecting…",
98
- standby: "READY",
99
- live: "LIVE",
100
- reconnecting: "Reconnecting…",
101
- ending: "Ending…",
102
- ended: "Livestream ended",
103
- error: "Something went wrong",
104
- },
105
- consent: "Recording consent",
106
- consentDetail: "I consent to recording and saving this livestream as video.",
107
- start: "Connect",
108
- startStandby: "Standby Mode",
109
- startLive: "Go Live Now",
110
- confirmStart: "Confirm start",
111
- afterThree: "Connect in 3 seconds",
112
- live: "LIVE",
113
- endLabel: "End livestream",
114
- endQuestion: "End this livestream?",
115
- end: "End",
116
- cancel: "Cancel",
117
- ended: "Livestream ended",
118
- retry: "Try again",
119
- errorDetail: "Technical details",
120
- offline: "Network offline",
121
- flip: "Flip camera",
122
- playLabel: "Start broadcasting",
123
- pauseLabel: "Pause broadcasting",
124
- mic: "Microphone",
125
- orientationLabel: "Frame",
126
- portrait: "9:16 Tall",
127
- landscape: "16:9 Wide",
128
- portraitHint: "Hold the phone upright while filming.",
129
- landscapeHint: "Hold the phone sideways while filming.",
130
- rotatePortrait: "Rotate the phone upright to match the 9:16 frame before going live.",
131
- rotateLandscape: "Rotate the phone sideways to match the 16:9 frame before going live.",
132
- lensLabel: "Lens",
133
- cameraLabel: "Camera",
134
- cameraFront: "Front",
135
- cameraBack: "Back",
136
- visibilityLabel: "Mode",
137
- visPublic: "Public",
138
- visPrivate: "Private",
139
- backToPreview: "Back to standby",
140
- },
141
- } as const;
142
-
143
- const errorTitle: Record<
144
- "vi" | "en",
145
- Record<ExpoSdkErrorCode, string>
146
- > = {
147
- vi: {
148
- PERMISSION_DENIED: "Cần quyền Camera và Micrô",
149
- NETWORK_UNAVAILABLE: "Không có kết nối mạng",
150
- TOKEN_EXPIRED: "Phiên xác thực đã hết hạn",
151
- SDK_UPGRADE_REQUIRED: "Cần cập nhật ứng dụng",
152
- UNSUPPORTED_RUNTIME: "Môi trường chạy không được hỗ trợ",
153
- SESSION_CONFLICT: "Đang có phiên phát khác",
154
- RECORDING_FAILED: "Không thể ghi hình",
155
- CONSENT_REQUIRED: "Cần đồng ý ghi hình",
156
- SDK_DISABLED: "Tính năng phát trực tiếp đang tắt",
157
- CONFIGURATION_ERROR: "Cấu hình SDK chưa đúng",
158
- INVALID_RESPONSE: "Phản hồi máy chủ không hợp lệ",
159
- TIMEOUT: "Yêu cầu quá thời gian",
160
- UNKNOWN: "Đã xảy ra lỗi",
161
- },
162
- en: {
163
- PERMISSION_DENIED: "Camera and Microphone access required",
164
- NETWORK_UNAVAILABLE: "No network connection",
165
- TOKEN_EXPIRED: "Authentication expired",
166
- SDK_UPGRADE_REQUIRED: "App update required",
167
- UNSUPPORTED_RUNTIME: "Unsupported runtime",
168
- SESSION_CONFLICT: "Another livestream is active",
169
- RECORDING_FAILED: "Recording failed",
170
- CONSENT_REQUIRED: "Recording consent required",
171
- SDK_DISABLED: "Livestreaming is disabled",
172
- CONFIGURATION_ERROR: "SDK configuration error",
173
- INVALID_RESPONSE: "Invalid server response",
174
- TIMEOUT: "Request timed out",
175
- UNKNOWN: "Something went wrong",
176
- },
177
- };
178
-
179
- const remediation: Record<
180
- "vi" | "en",
181
- Record<ExpoSdkErrorCode, string>
182
- > = {
183
- vi: {
184
- PERMISSION_DENIED:
185
- "Mở Cài đặt và cho phép Camera cùng Micrô, sau đó thử lại.",
186
- NETWORK_UNAVAILABLE:
187
- "Kiểm tra Wi-Fi hoặc dữ liệu di động rồi thử lại.",
188
- TOKEN_EXPIRED:
189
- "Phiên xác thực đã hết hạn. Hãy kết nối mạng và thử lại.",
190
- SDK_UPGRADE_REQUIRED:
191
- "Cập nhật ứng dụng lên bản mới nhất và cài lại Development Build.",
192
- UNSUPPORTED_RUNTIME:
193
- "Cài Development Build trên iOS hoặc Android; Expo Go và web không được hỗ trợ.",
194
- SESSION_CONFLICT:
195
- "Kết thúc phiên phát đang hoạt động trước khi bắt đầu phiên mới.",
196
- RECORDING_FAILED:
197
- "Không thể tạo bản ghi máy chủ. Hãy thử lại hoặc liên hệ hỗ trợ.",
198
- CONSENT_REQUIRED:
199
- "Đồng ý ghi hình trước khi bắt đầu phát trực tiếp.",
200
- SDK_DISABLED:
201
- "Tính năng đang bị tắt cho ứng dụng này. Hãy liên hệ quản trị viên.",
202
- CONFIGURATION_ERROR:
203
- "Kiểm tra cấu hình Provider và config plugin, sau đó rebuild ứng dụng.",
204
- INVALID_RESPONSE:
205
- "Máy chủ trả về dữ liệu không hợp lệ. Hãy thử lại hoặc liên hệ hỗ trợ.",
206
- TIMEOUT: "Yêu cầu quá thời gian. Kiểm tra kết nối rồi thử lại.",
207
- UNKNOWN: "Thử lại. Nếu lỗi tiếp diễn, hãy liên hệ hỗ trợ.",
208
- },
209
- en: {
210
- PERMISSION_DENIED:
211
- "Open Settings and allow Camera and Microphone, then try again.",
212
- NETWORK_UNAVAILABLE: "Check Wi-Fi or mobile data, then try again.",
213
- TOKEN_EXPIRED:
214
- "Your authentication has expired. Connect to the network and try again.",
215
- SDK_UPGRADE_REQUIRED:
216
- "Update the app to the latest version and install a new Development Build.",
217
- UNSUPPORTED_RUNTIME:
218
- "Install an iOS or Android Development Build; Expo Go and web are unsupported.",
219
- SESSION_CONFLICT:
220
- "End the active livestream before starting another one.",
221
- RECORDING_FAILED:
222
- "The server recording could not be created. Try again or contact support.",
223
- CONSENT_REQUIRED: "Accept recording consent before going live.",
224
- SDK_DISABLED:
225
- "Livestreaming is disabled for this app. Contact an administrator.",
226
- CONFIGURATION_ERROR:
227
- "Check the Provider and config plugin setup, then rebuild the app.",
228
- INVALID_RESPONSE:
229
- "The server returned invalid data. Try again or contact support.",
230
- TIMEOUT: "The request timed out. Check your connection and try again.",
231
- UNKNOWN: "Try again. If the problem continues, contact support.",
232
- },
233
- };
234
-
235
- export function LivestreamScreen(props: LivestreamScreenProps) {
236
- const state = useLivestream();
237
- const strings = copy[state.locale];
238
- const insets = useSafeAreaInsets();
239
- const { width: windowWidth, height: windowHeight } = useWindowDimensions();
240
- // UI ngang: panel điều khiển chuyển sang cạnh phải như app camera.
241
- const landscapeUi = windowWidth > windowHeight;
242
- const panelWidth = Math.min(360, Math.round(windowWidth * 0.42));
243
- const selectedOrientation = state.captureOrientation ?? "portrait";
244
- // Chặn vào live khi máy đang cầm lệch với khung 9:16/16:9 đã chọn.
245
- const orientationMismatch =
246
- props.physicalOrientation != null &&
247
- props.physicalOrientation !== selectedOrientation;
248
- const rotateWarning =
249
- selectedOrientation === "landscape"
250
- ? strings.rotateLandscape
251
- : strings.rotatePortrait;
252
- // Consent ghi nhớ theo consentVersion — lần mở app sau tự kết nối để
253
- // dashboard toàn quyền điều khiển từ lúc app bật (mô hình camera box).
254
- const consentStorageKey = `@pickleball/consent:${props.input.consentVersion}`;
255
- const storedConsent = (() => {
256
- try {
257
- return globalThis.localStorage?.getItem(consentStorageKey) === "yes";
258
- } catch {
259
- return false;
260
- }
261
- })();
262
- const [consented, setConsented] = useState(storedConsent);
263
- const [confirmingEnd, setConfirmingEnd] = useState(false);
264
- // Chế độ hiển thị của phiên; mặc định theo input của app.
265
- const [visibility, setVisibility] = useState<"public" | "private">(
266
- props.input.visibility ?? "public",
267
- );
268
- // Trong phiên (standby/live/reconnecting): camera full màn hình, nút
269
- // floating tự ẩn sau 10s; chạm để hiện.
270
- const inLive =
271
- state.state === "standby" ||
272
- state.state === "live" ||
273
- state.state === "reconnecting";
274
- const isPublishing = state.state === "live";
275
- const [liveControlsVisible, setLiveControlsVisible] = useState(true);
276
- const [controlsTick, setControlsTick] = useState(0);
277
- const showLiveControls = () => {
278
- setLiveControlsVisible(true);
279
- setControlsTick((tick) => tick + 1);
280
- };
281
-
282
- useEffect(() => {
283
- if (inLive) setLiveControlsVisible(true);
284
- }, [inLive]);
285
-
286
- useEffect(() => {
287
- if (!inLive || !liveControlsVisible || confirmingEnd) return;
288
- const timer = setTimeout(() => setLiveControlsVisible(false), 10_000);
289
- return () => clearTimeout(timer);
290
- }, [inLive, liveControlsVisible, confirmingEnd, controlsTick]);
291
-
292
- useEffect(() => {
293
- if (state.state === "idle") void state.prepare().catch(() => undefined);
294
- }, [state.prepare, state.state]);
295
-
296
- // Kết nối vào phòng (standby hoặc autoPublish) — lưu consent để các
297
- // lần mở app sau tự kết nối.
298
- const connect = (autoPublish = false) => {
299
- try {
300
- globalThis.localStorage?.setItem(consentStorageKey, "yes");
301
- } catch {
302
- // Storage không khả dụng — chỉ mất auto-connect lần sau.
303
- }
304
- void state
305
- .start({ ...props.input, visibility }, { autoPublish })
306
- .catch(() => undefined);
307
- };
308
-
309
- // Auto-connect chỉ khi props.autoConnect === true (ví dụ box tự động).
310
- const autoConnectedRef = React.useRef(false);
311
- useEffect(() => {
312
- if (props.autoConnect !== true || autoConnectedRef.current) return;
313
- if (state.state !== "preview" || !storedConsent) return;
314
- autoConnectedRef.current = true;
315
- connect();
316
- // eslint-disable-next-line react-hooks/exhaustive-deps
317
- }, [props.autoConnect, state.state, storedConsent]);
318
-
319
- const theme = state.theme;
320
- const bodyFont = fontOf(theme);
321
- const boldFont = fontOf(theme, true);
322
- const displayFont = theme.displayFontFamily
323
- ? { fontFamily: theme.displayFontFamily }
324
- : boldFont;
325
- // Pill compact — cùng ngôn ngữ rounded-full của dashboard.
326
- const button = (color: string): ViewStyle => ({
327
- minHeight: 40,
328
- minWidth: 44,
329
- paddingHorizontal: 16,
330
- alignItems: "center",
331
- justifyContent: "center",
332
- backgroundColor: color,
333
- borderRadius: 999,
334
- });
335
- const secondaryButton: ViewStyle = {
336
- ...button(theme.elevated),
337
- borderWidth: 1,
338
- borderColor: theme.hairline,
339
- };
340
- // CTA chính: accent + quầng sáng (glow) — chữ ký của design system.
341
- const cta = (enabled: boolean): ViewStyle => ({
342
- minHeight: 48,
343
- alignItems: "center",
344
- justifyContent: "center",
345
- borderRadius: 999,
346
- backgroundColor: enabled ? theme.accent : theme.elevated,
347
- ...(enabled
348
- ? {
349
- shadowColor: theme.accent,
350
- shadowOpacity: 0.45,
351
- shadowRadius: 14,
352
- shadowOffset: { width: 0, height: 0 },
353
- elevation: 8,
354
- }
355
- : { borderWidth: 1, borderColor: theme.hairline }),
356
- });
357
- // Nút tròn floating cho màn live (icon-only).
358
- const fab = (bg: string): ViewStyle => ({
359
- width: 48,
360
- height: 48,
361
- borderRadius: 24,
362
- alignItems: "center",
363
- justifyContent: "center",
364
- backgroundColor: bg,
365
- borderWidth: 1,
366
- borderColor: theme.hairline,
367
- });
368
- const statusLabel = statusText(state.state, strings);
369
-
370
- return (
371
- <View
372
- style={{
373
- flex: 1,
374
- flexDirection: landscapeUi ? "row" : "column",
375
- backgroundColor: theme.background,
376
- paddingTop: insets.top,
377
- paddingBottom: insets.bottom,
378
- paddingLeft: insets.left,
379
- paddingRight: insets.right,
380
- ...props.style,
381
- }}
382
- >
383
- <View style={{ flex: 1 }}>
384
- {props.renderPreview ? (
385
- props.renderPreview(state)
386
- ) : inLive ? (
387
- // Live: camera full màn hình; chạm để hiện nút điều khiển.
388
- <Pressable style={{ flex: 1 }} onPress={showLiveControls}>
389
- <DefaultPreview state={state} />
390
- </Pressable>
391
- ) : (
392
- <AspectBox
393
- ratio={selectedOrientation === "landscape" ? 16 / 9 : 9 / 16}
394
- background={theme.background}
395
- >
396
- <DefaultPreview state={state} />
397
- </AspectBox>
398
- )}
399
- </View>
400
- <View
401
- style={{
402
- position: "absolute",
403
- top: insets.top + 12,
404
- left: 12,
405
- right: 12,
406
- gap: 8,
407
- }}
408
- >
409
- {props.renderHeader ? (
410
- props.renderHeader(state)
411
- ) : (
412
- <View
413
- style={{
414
- alignSelf: "flex-start",
415
- flexDirection: "row",
416
- alignItems: "center",
417
- gap: 8,
418
- paddingVertical: 6,
419
- paddingHorizontal: 12,
420
- borderRadius: 999,
421
- borderWidth: 1,
422
- borderColor: theme.hairline,
423
- backgroundColor: theme.overlay,
424
- ...(state.state === "live"
425
- ? { shadowColor: theme.accent, shadowOpacity: 0.4, shadowRadius: 10, shadowOffset: { width: 0, height: 0 }, elevation: 6 }
426
- : {}),
427
- }}
428
- >
429
- {state.state === "live" ? (
430
- <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: theme.accent }} />
431
- ) : null}
432
- <Text
433
- selectable
434
- style={[
435
- {
436
- color: state.state === "live" ? theme.accent : theme.foreground,
437
- fontSize: 12,
438
- fontWeight: "700",
439
- letterSpacing: 0.6,
440
- },
441
- displayFont,
442
- ]}
443
- >
444
- {statusLabel}
445
- </Text>
446
- {state.state === "live" ? (
447
- <Text selectable style={[{ color: theme.textSecondary, fontSize: 12, fontVariant: ["tabular-nums"] }, bodyFont]}>
448
- {formatElapsed(state.elapsedMs)}
449
- </Text>
450
- ) : null}
451
- {/* Mã ngắn trùng với mã trên dashboard — 8 sân chạy cùng lúc thì
452
- cần thứ đối chiếu được, tiêu đề không đủ phân biệt. */}
453
- {state.sessionId ? (
454
- <Text
455
- selectable
456
- style={[{ color: theme.textSecondary, fontSize: 12, letterSpacing: 1 }, bodyFont]}
457
- >
458
- {sessionShortCode(state.sessionId)}
459
- </Text>
460
- ) : null}
461
- {inLive && state.deviceMetrics ? (
462
- <Text
463
- selectable
464
- numberOfLines={1}
465
- style={[{ color: theme.textSecondary, fontSize: 12 }, bodyFont]}
466
- >
467
- {[
468
- state.deviceMetrics.batteryPct !== undefined
469
- ? `🔋${state.deviceMetrics.batteryPct}%${state.deviceMetrics.charging ? "⚡" : ""}`
470
- : null,
471
- state.deviceMetrics.netType ? `📶${state.deviceMetrics.netType}` : null,
472
- state.deviceMetrics.connQuality ?? null,
473
- ]
474
- .filter(Boolean)
475
- .join(" ")}
476
- </Text>
477
- ) : null}
478
- {!state.isOnline ? <Text selectable style={[{ color: theme.danger, fontSize: 12 }, bodyFont]}>{strings.offline}</Text> : null}
479
- </View>
480
- )}
481
- </View>
482
-
483
-
484
- {!inLive || state.error ? (
485
- <View style={landscapeUi ? { width: panelWidth } : null}>
486
- <ScrollView
487
- style={landscapeUi ? { flex: 1 } : undefined}
488
- scrollEnabled={landscapeUi}
489
- contentContainerStyle={
490
- landscapeUi ? { flexGrow: 1, justifyContent: "center" } : undefined
491
- }
492
- >
493
- {state.error ? (
494
- props.renderError ? props.renderError(state) : (
495
- <View style={{ padding: 14, gap: 10, backgroundColor: theme.overlay }}>
496
- <Text selectable accessibilityRole="alert" style={[{ color: theme.foreground, fontSize: 15, fontWeight: "700" }, displayFont]}>
497
- {errorTitle[state.locale][state.error.code]}
498
- </Text>
499
- <Text selectable style={[{ color: theme.textSecondary, fontSize: 13 }, bodyFont]}>
500
- {remediation[state.locale][state.error.code]}
501
- </Text>
502
- <Text selectable style={[{ color: theme.muted, fontSize: 11 }, bodyFont]}>
503
- {strings.errorDetail}: {state.error.message}
504
- </Text>
505
- <Pressable accessibilityRole="button" onPress={() => void state.retry()} style={cta(true)}>
506
- <Text style={[{ color: theme.ink, fontSize: 14, fontWeight: "800" }, boldFont]}>{strings.retry}</Text>
507
- </Pressable>
508
- </View>
509
- )
510
- ) : props.renderControls ? (
511
- props.renderControls(state)
512
- ) : (
513
- <View style={{ padding: 14, gap: 10, backgroundColor: theme.overlay }}>
514
- {state.state === "preview" ? (
515
- <>
516
- <ControlRow label={strings.orientationLabel} theme={theme}>
517
- <Segmented
518
- theme={theme}
519
- value={selectedOrientation}
520
- onSelect={(value) =>
521
- void state.setCaptureOrientation(value as "portrait" | "landscape")
522
- }
523
- options={[
524
- { value: "portrait", label: strings.portrait },
525
- { value: "landscape", label: strings.landscape },
526
- ]}
527
- />
528
- </ControlRow>
529
- <ControlRow label={strings.visibilityLabel} theme={theme}>
530
- <Segmented
531
- theme={theme}
532
- value={visibility}
533
- onSelect={(value) => setVisibility(value as "public" | "private")}
534
- options={[
535
- { value: "public", label: strings.visPublic },
536
- { value: "private", label: strings.visPrivate },
537
- ]}
538
- />
539
- </ControlRow>
540
- <ControlRow label={strings.cameraLabel} theme={theme}>
541
- <Segmented
542
- theme={theme}
543
- value={state.isCameraFront ? "front" : "back"}
544
- onSelect={(value) => {
545
- if ((value === "front") !== state.isCameraFront) {
546
- void state.switchCamera();
547
- }
548
- }}
549
- options={[
550
- { value: "back", label: strings.cameraBack },
551
- { value: "front", label: strings.cameraFront },
552
- ]}
553
- />
554
- </ControlRow>
555
- {(() => {
556
- // Chỉ liệt kê ống kính vật lý mặt sau; bỏ thiết bị ảo đa ống
557
- // kính (Dual/Triple) vì WebRTC không điều khiển được auto-switch.
558
- const backLenses = state.cameras.filter(
559
- (camera) =>
560
- camera.facing === "environment" &&
561
- !/dual|triple/i.test(camera.label),
562
- );
563
- if (backLenses.length < 2 || state.isCameraFront) return null;
564
- const lensName = (label: string) =>
565
- /ultra\s*wide/i.test(label)
566
- ? "0.5x"
567
- : /telephoto/i.test(label)
568
- ? "Tele"
569
- : "1x";
570
- return (
571
- <ControlRow label={strings.lensLabel} theme={theme}>
572
- <Segmented
573
- theme={theme}
574
- value={state.selectedCameraId}
575
- onSelect={(deviceId) => void state.selectCamera(deviceId)}
576
- options={backLenses.map((camera) => ({
577
- value: camera.deviceId,
578
- label: lensName(camera.label),
579
- accessibilityLabel: camera.label,
580
- }))}
581
- />
582
- </ControlRow>
583
- );
584
- })()}
585
- <Text selectable style={[{ color: theme.muted, fontSize: 11 }, bodyFont]}>
586
- {selectedOrientation === "landscape" ? strings.landscapeHint : strings.portraitHint}
587
- </Text>
588
- <Pressable
589
- accessibilityRole="checkbox"
590
- accessibilityLabel={strings.consent}
591
- accessibilityState={{ checked: consented }}
592
- onPress={() => setConsented((value) => !value)}
593
- style={{ minHeight: 40, flexDirection: "row", alignItems: "center", gap: 10 }}
594
- >
595
- <View
596
- style={{
597
- width: 22,
598
- height: 22,
599
- borderRadius: theme.radiusSm,
600
- borderWidth: 1.5,
601
- borderColor: consented ? theme.accent : theme.hairline,
602
- backgroundColor: consented ? theme.accent : theme.surface,
603
- alignItems: "center",
604
- justifyContent: "center",
605
- }}
606
- >
607
- {consented ? (
608
- <Text style={{ color: theme.ink, fontSize: 13, fontWeight: "800" }}>✓</Text>
609
- ) : null}
610
- </View>
611
- <Text selectable style={[{ color: theme.textSecondary, fontSize: 13, flex: 1 }, bodyFont]}>
612
- {strings.consentDetail}
613
- </Text>
614
- </Pressable>
615
- <View style={{ flexDirection: "row", gap: 8 }}>
616
- <Pressable
617
- accessibilityRole="button"
618
- accessibilityLabel={strings.startStandby}
619
- accessibilityState={{ disabled: !consented }}
620
- disabled={!consented}
621
- onPress={() => connect(false)}
622
- style={[
623
- secondaryButton,
624
- {
625
- flex: 1,
626
- minHeight: 48,
627
- backgroundColor: consented ? theme.elevated : theme.surface,
628
- },
629
- ]}
630
- >
631
- <Text
632
- style={[
633
- {
634
- color: consented ? theme.foreground : theme.muted,
635
- fontSize: 14,
636
- fontWeight: "800",
637
- },
638
- boldFont,
639
- ]}
640
- >
641
- {`🟡 ${strings.startStandby}`}
642
- </Text>
643
- </Pressable>
644
- <Pressable
645
- accessibilityRole="button"
646
- accessibilityLabel={strings.startLive}
647
- accessibilityState={{ disabled: !consented }}
648
- disabled={!consented}
649
- onPress={() => connect(true)}
650
- style={[cta(consented), { flex: 1 }]}
651
- >
652
- <Text
653
- style={[
654
- {
655
- color: consented ? theme.ink : theme.muted,
656
- fontSize: 14,
657
- fontWeight: "800",
658
- },
659
- boldFont,
660
- ]}
661
- >
662
- {`🟢 ${strings.startLive}`}
663
- </Text>
664
- </Pressable>
665
- </View>
666
- </>
667
- ) : null}
668
- {state.state === "ended" ? (
669
- <View style={{ gap: 10 }}>
670
- <Text selectable style={[{ color: theme.foreground, textAlign: "center", fontSize: 15, fontWeight: "700" }, displayFont]}>
671
- {strings.ended}
672
- </Text>
673
- <Pressable
674
- accessibilityRole="button"
675
- onPress={() => {
676
- // Consent đã lưu bền — quay về preview chỉ cần một chạm
677
- // "Bắt đầu kết nối" để vào phiên mới (không auto để người
678
- // dùng chủ động sau khi vừa kết thúc).
679
- setConfirmingEnd(false);
680
- props.onEnded?.();
681
- void state.prepare();
682
- }}
683
- style={cta(true)}
684
- >
685
- <Text style={[{ color: theme.ink, fontSize: 14, fontWeight: "800" }, boldFont]}>
686
- {strings.backToPreview}
687
- </Text>
688
- </Pressable>
689
- </View>
690
- ) : null}
691
- </View>
692
- )}
693
- </ScrollView>
694
- </View>
695
- ) : null}
696
-
697
- {inLive && liveControlsVisible && !confirmingEnd ? (
698
- <View
699
- style={{
700
- position: "absolute",
701
- right: 16,
702
- bottom: insets.bottom + 24,
703
- gap: 12,
704
- alignItems: "center",
705
- }}
706
- >
707
- {state.state === "standby" && orientationMismatch ? (
708
- <Text
709
- style={[
710
- {
711
- maxWidth: 200,
712
- textAlign: "center",
713
- color: theme.danger,
714
- fontSize: 11,
715
- fontWeight: "700",
716
- },
717
- boldFont,
718
- ]}
719
- >
720
- {rotateWarning}
721
- </Text>
722
- ) : null}
723
- <Pressable
724
- accessibilityRole="button"
725
- accessibilityLabel={isPublishing ? strings.pauseLabel : strings.playLabel}
726
- accessibilityState={{
727
- disabled: state.state === "standby" && orientationMismatch,
728
- }}
729
- disabled={state.state === "standby" && orientationMismatch}
730
- onPress={() => {
731
- showLiveControls();
732
- if (isPublishing) void state.stopPublishing();
733
- else void state.startPublishing();
734
- }}
735
- style={[
736
- fab(
737
- state.state === "standby" && orientationMismatch
738
- ? theme.elevated
739
- : theme.accent,
740
- ),
741
- { borderColor: theme.accent },
742
- ]}
743
- >
744
- <Text style={{ fontSize: 18, color: theme.ink, fontWeight: "800" }}>
745
- {isPublishing ? "⏸" : "▶"}
746
- </Text>
747
- </Pressable>
748
- <Pressable
749
- accessibilityRole="button"
750
- accessibilityLabel={strings.flip}
751
- onPress={() => {
752
- showLiveControls();
753
- void state.switchCamera();
754
- }}
755
- style={fab(theme.overlay)}
756
- >
757
- <Text style={{ fontSize: 20 }}>🔄</Text>
758
- </Pressable>
759
- <Pressable
760
- accessibilityRole="switch"
761
- accessibilityLabel={strings.mic}
762
- accessibilityState={{ checked: state.isMicrophoneEnabled }}
763
- onPress={() => {
764
- showLiveControls();
765
- void state.setMicrophoneEnabled(!state.isMicrophoneEnabled);
766
- }}
767
- style={[
768
- fab(theme.overlay),
769
- state.isMicrophoneEnabled ? null : { borderColor: theme.danger },
770
- ]}
771
- >
772
- <Text style={{ fontSize: 20 }}>{state.isMicrophoneEnabled ? "🎙️" : "🔇"}</Text>
773
- </Pressable>
774
- <Pressable
775
- accessibilityRole="button"
776
- accessibilityLabel={strings.endLabel}
777
- onPress={() => setConfirmingEnd(true)}
778
- style={[fab(theme.danger), { borderColor: theme.danger }]}
779
- >
780
- <Text style={{ fontSize: 18, color: theme.ink, fontWeight: "800" }}>■</Text>
781
- </Pressable>
782
- </View>
783
- ) : null}
784
-
785
- {confirmingEnd ? (
786
- <View
787
- style={{
788
- position: "absolute",
789
- inset: 0,
790
- alignItems: "center",
791
- justifyContent: "center",
792
- backgroundColor: theme.overlay,
793
- }}
794
- >
795
- <View
796
- style={{
797
- width: 280,
798
- gap: 10,
799
- padding: 16,
800
- borderRadius: theme.radius,
801
- borderWidth: 1,
802
- borderColor: theme.hairline,
803
- backgroundColor: theme.surface,
804
- }}
805
- >
806
- <Text selectable style={[{ color: theme.foreground, fontSize: 15, fontWeight: "700" }, displayFont]}>
807
- {strings.endQuestion}
808
- </Text>
809
- <Pressable
810
- accessibilityRole="button"
811
- onPress={() => { setConfirmingEnd(false); void state.stop("user"); }}
812
- style={button(theme.danger)}
813
- >
814
- <Text style={[{ color: theme.ink, fontSize: 14, fontWeight: "800" }, boldFont]}>{strings.end}</Text>
815
- </Pressable>
816
- <Pressable accessibilityRole="button" onPress={() => setConfirmingEnd(false)} style={secondaryButton}>
817
- <Text style={[{ color: theme.textSecondary, fontSize: 13, fontWeight: "600" }, bodyFont]}>{strings.cancel}</Text>
818
- </Pressable>
819
- </View>
820
- </View>
821
- ) : null}
822
- </View>
823
- );
824
- }
825
-
826
- type ThemeLike = RenderState["theme"];
827
-
828
- const fontOf = (theme: ThemeLike, bold = false) =>
829
- bold
830
- ? { fontFamily: theme.fontFamilyBold ?? theme.fontFamily }
831
- : { fontFamily: theme.fontFamily };
832
-
833
- /** Segmented pill — cùng ngôn ngữ với bộ lọc rounded-full của dashboard. */
834
- function Segmented({
835
- options,
836
- value,
837
- onSelect,
838
- theme,
839
- }: {
840
- options: Array<{ value: string; label: string; accessibilityLabel?: string }>;
841
- value: string | null;
842
- onSelect(value: string): void;
843
- theme: ThemeLike;
844
- }) {
845
- return (
846
- <View
847
- style={{
848
- flex: 1,
849
- flexDirection: "row",
850
- padding: 3,
851
- gap: 3,
852
- borderRadius: 999,
853
- borderWidth: 1,
854
- borderColor: theme.hairline,
855
- backgroundColor: theme.surface,
856
- }}
857
- >
858
- {options.map((option) => {
859
- const selected = option.value === value;
860
- return (
861
- <Pressable
862
- key={option.value}
863
- accessibilityRole="button"
864
- accessibilityLabel={option.accessibilityLabel ?? option.label}
865
- accessibilityState={{ selected }}
866
- onPress={() => onSelect(option.value)}
867
- style={{
868
- flex: 1,
869
- minHeight: 32,
870
- alignItems: "center",
871
- justifyContent: "center",
872
- borderRadius: 999,
873
- backgroundColor: selected ? theme.accent : "transparent",
874
- }}
875
- >
876
- <Text
877
- numberOfLines={1}
878
- style={[
879
- {
880
- fontSize: 12,
881
- fontWeight: "700",
882
- color: selected ? theme.ink : theme.textSecondary,
883
- },
884
- fontOf(theme, true),
885
- ]}
886
- >
887
- {option.label}
888
- </Text>
889
- </Pressable>
890
- );
891
- })}
892
- </View>
893
- );
894
- }
895
-
896
- /** Hàng cấu hình compact: nhãn nhỏ bên trái, control chiếm phần còn lại. */
897
- function ControlRow({
898
- label,
899
- theme,
900
- children,
901
- }: {
902
- label: string;
903
- theme: ThemeLike;
904
- children: ReactNode;
905
- }) {
906
- return (
907
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
908
- <Text
909
- numberOfLines={1}
910
- style={[
911
- {
912
- width: 78,
913
- fontSize: 10,
914
- letterSpacing: 1,
915
- textTransform: "uppercase",
916
- fontWeight: "700",
917
- color: theme.muted,
918
- },
919
- fontOf(theme, true),
920
- ]}
921
- >
922
- {label}
923
- </Text>
924
- {children}
925
- </View>
926
- );
927
- }
928
-
929
- /**
930
- * Khung cố định theo tỷ lệ đã chốt (9:16 hoặc 16:9), letterbox giữa vùng còn
931
- * lại — người quay thấy đúng khung mà người xem sẽ thấy, bất kể máy xoay.
932
- */
933
- function AspectBox({
934
- ratio,
935
- background,
936
- children,
937
- }: {
938
- ratio: number;
939
- background: string;
940
- children: ReactNode;
941
- }) {
942
- const [size, setSize] = useState<{ w: number; h: number } | null>(null);
943
- return (
944
- <View
945
- style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: background }}
946
- onLayout={(event) => {
947
- const { width, height } = event.nativeEvent.layout;
948
- setSize({ w: width, h: height });
949
- }}
950
- >
951
- {size ? (
952
- <View
953
- style={{
954
- width: Math.min(size.w, size.h * ratio),
955
- height: Math.min(size.h, size.w / ratio),
956
- overflow: "hidden",
957
- }}
958
- >
959
- {children}
960
- </View>
961
- ) : null}
962
- </View>
963
- );
964
- }
965
-
966
- function DefaultPreview({ state }: { state: RenderState }) {
967
- const preview = state.previewTrack as
968
- | LocalPreviewReference
969
- | { kind?: string }
970
- | null;
971
- if (!preview) return <View style={{ flex: 1, backgroundColor: state.theme.background }} />;
972
- // Mode device-rtmp: khung hình đi thẳng trong native, JS không có track nào để
973
- // bind. Render view native của HaishinKit; thiếu nhánh này thì màn TỐI ĐEN dù
974
- // camera đã mở và đang encode bình thường.
975
- if ((preview as { kind?: string }).kind === "rtmp") {
976
- const rtmp = require("@pickleball/rtmp-native");
977
- return <rtmp.PickleballRtmpPreviewView style={{ flex: 1 }} />;
978
- }
979
- const liveKit = require("@livekit/react-native");
980
- const liveKitPreview = preview as LocalPreviewReference;
981
- // restartTrack thay mediaStreamTrack bên trong cùng một track object nên
982
- // React không thấy prop đổi; remount view theo camera đang chọn để render
983
- // đúng stream mới sau khi lật camera / chọn ống kính.
984
- const remountKey = `${state.selectedCameraId ?? "auto"}:${state.isCameraFront ? "front" : "back"}`;
985
- return liveKitPreview.trackRef ? (
986
- <liveKit.VideoTrack key={remountKey} trackRef={liveKitPreview.trackRef} mirror={state.isCameraFront} objectFit="cover" style={{ flex: 1 }} />
987
- ) : (
988
- <liveKit.VideoView key={remountKey} videoTrack={liveKitPreview.videoTrack} mirror={state.isCameraFront} objectFit="cover" style={{ flex: 1 }} />
989
- );
990
- }
991
-
992
- function formatElapsed(milliseconds: number) {
993
- const total = Math.floor(milliseconds / 1_000);
994
- const hours = Math.floor(total / 3_600);
995
- const minutes = Math.floor((total % 3_600) / 60);
996
- const seconds = total % 60;
997
- return [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
998
- }
999
-
1000
- function statusText(state: LivestreamState, strings: typeof copy.vi | typeof copy.en) {
1001
- return strings.states[state];
1002
- }