@pickleball/expo-sdk 0.1.1

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