@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app.plugin.js +6 -0
- package/dist/chunk-VHLUJQG5.js +17 -0
- package/dist/index.cjs +6284 -0
- package/dist/index.d.cts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +6186 -0
- package/dist/plugin.cjs +153 -0
- package/dist/plugin.d.cts +21 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +131 -0
- package/dist/version.cjs +35 -0
- package/dist/version.d.cts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +6 -0
- package/package.json +126 -0
- package/src/contracts.ts +211 -0
- package/src/engine.ts +1332 -0
- package/src/errors.ts +128 -0
- package/src/http-session-provider.ts +259 -0
- package/src/index.ts +16 -0
- package/src/livekit-room-adapter.ts +353 -0
- package/src/livestream-screen.tsx +980 -0
- package/src/match/contracts.ts +137 -0
- package/src/match/engine.ts +702 -0
- package/src/match/persistence/default-store.native.ts +20 -0
- package/src/match/persistence/default-store.ts +11 -0
- package/src/match/persistence/index.ts +6 -0
- package/src/match/persistence/memory-driver.ts +255 -0
- package/src/match/persistence/migrations.ts +40 -0
- package/src/match/persistence/sqlite-driver.ts +348 -0
- package/src/match/persistence/store.ts +497 -0
- package/src/match/persistence/types.ts +98 -0
- package/src/match/provider.tsx +728 -0
- package/src/match/remote/deduplicator.ts +54 -0
- package/src/match/remote/encoding.ts +12 -0
- package/src/match/remote/hid-gesture.ts +61 -0
- package/src/match/remote/index.ts +5 -0
- package/src/match/remote/native-service.ts +62 -0
- package/src/match/remote/parser.ts +134 -0
- package/src/match/remote/types.ts +116 -0
- package/src/match/replay.ts +102 -0
- package/src/match/state.ts +72 -0
- package/src/match/validation.ts +375 -0
- package/src/match/voice/assets/court-left.wav +0 -0
- package/src/match/voice/assets/court-right.wav +0 -0
- package/src/match/voice/assets/court-switch.wav +0 -0
- package/src/match/voice/assets/game-point.wav +0 -0
- package/src/match/voice/assets/game-won.wav +0 -0
- package/src/match/voice/assets/match-won.wav +0 -0
- package/src/match/voice/assets/number-0.wav +0 -0
- package/src/match/voice/assets/number-1.wav +0 -0
- package/src/match/voice/assets/number-10.wav +0 -0
- package/src/match/voice/assets/number-11.wav +0 -0
- package/src/match/voice/assets/number-12.wav +0 -0
- package/src/match/voice/assets/number-13.wav +0 -0
- package/src/match/voice/assets/number-14.wav +0 -0
- package/src/match/voice/assets/number-15.wav +0 -0
- package/src/match/voice/assets/number-16.wav +0 -0
- package/src/match/voice/assets/number-17.wav +0 -0
- package/src/match/voice/assets/number-18.wav +0 -0
- package/src/match/voice/assets/number-19.wav +0 -0
- package/src/match/voice/assets/number-2.wav +0 -0
- package/src/match/voice/assets/number-20.wav +0 -0
- package/src/match/voice/assets/number-3.wav +0 -0
- package/src/match/voice/assets/number-30.wav +0 -0
- package/src/match/voice/assets/number-4.wav +0 -0
- package/src/match/voice/assets/number-40.wav +0 -0
- package/src/match/voice/assets/number-5.wav +0 -0
- package/src/match/voice/assets/number-50.wav +0 -0
- package/src/match/voice/assets/number-6.wav +0 -0
- package/src/match/voice/assets/number-60.wav +0 -0
- package/src/match/voice/assets/number-7.wav +0 -0
- package/src/match/voice/assets/number-70.wav +0 -0
- package/src/match/voice/assets/number-8.wav +0 -0
- package/src/match/voice/assets/number-80.wav +0 -0
- package/src/match/voice/assets/number-9.wav +0 -0
- package/src/match/voice/assets/number-90.wav +0 -0
- package/src/match/voice/assets/number-lam.wav +0 -0
- package/src/match/voice/assets/number-mot.wav +0 -0
- package/src/match/voice/assets/score-deu-0.wav +0 -0
- package/src/match/voice/assets/score-deu-1.wav +0 -0
- package/src/match/voice/assets/score-deu-10.wav +0 -0
- package/src/match/voice/assets/score-deu-11.wav +0 -0
- package/src/match/voice/assets/score-deu-12.wav +0 -0
- package/src/match/voice/assets/score-deu-13.wav +0 -0
- package/src/match/voice/assets/score-deu-14.wav +0 -0
- package/src/match/voice/assets/score-deu-15.wav +0 -0
- package/src/match/voice/assets/score-deu-16.wav +0 -0
- package/src/match/voice/assets/score-deu-17.wav +0 -0
- package/src/match/voice/assets/score-deu-18.wav +0 -0
- package/src/match/voice/assets/score-deu-19.wav +0 -0
- package/src/match/voice/assets/score-deu-2.wav +0 -0
- package/src/match/voice/assets/score-deu-20.wav +0 -0
- package/src/match/voice/assets/score-deu-3.wav +0 -0
- package/src/match/voice/assets/score-deu-30.wav +0 -0
- package/src/match/voice/assets/score-deu-4.wav +0 -0
- package/src/match/voice/assets/score-deu-40.wav +0 -0
- package/src/match/voice/assets/score-deu-5.wav +0 -0
- package/src/match/voice/assets/score-deu-50.wav +0 -0
- package/src/match/voice/assets/score-deu-6.wav +0 -0
- package/src/match/voice/assets/score-deu-60.wav +0 -0
- package/src/match/voice/assets/score-deu-7.wav +0 -0
- package/src/match/voice/assets/score-deu-70.wav +0 -0
- package/src/match/voice/assets/score-deu-8.wav +0 -0
- package/src/match/voice/assets/score-deu-80.wav +0 -0
- package/src/match/voice/assets/score-deu-9.wav +0 -0
- package/src/match/voice/assets/score-deu-90.wav +0 -0
- package/src/match/voice/assets/score-deu-lam.wav +0 -0
- package/src/match/voice/assets/score-deu-mot.wav +0 -0
- package/src/match/voice/assets/server-1.wav +0 -0
- package/src/match/voice/assets/server-2.wav +0 -0
- package/src/match/voice/assets/service-change.wav +0 -0
- package/src/match/voice/assets/test-speaker.wav +0 -0
- package/src/match/voice/audio.ts +231 -0
- package/src/match/voice/bundled-assets.native.ts +47 -0
- package/src/match/voice/bundled-assets.ts +3 -0
- package/src/match/voice/gemini-engine.ts +167 -0
- package/src/match/voice/index.ts +7 -0
- package/src/match/voice/phrases.ts +185 -0
- package/src/match/voice/scheduler.ts +205 -0
- package/src/match/voice/service.ts +147 -0
- package/src/match/voice/types.ts +118 -0
- package/src/native-runtime.ts +241 -0
- package/src/plugin.ts +145 -0
- package/src/provider.tsx +176 -0
- 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
|
+
}
|