@pickleball/expo-sdk 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +155 -101
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +14 -2
- package/dist/advanced.d.cts +2 -2
- package/dist/advanced.d.ts +2 -2
- package/dist/advanced.js +17 -5
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-62YV3PCD.cjs +139 -0
- package/dist/chunk-6F3HN6US.js +50 -0
- package/dist/chunk-6YE5BL3A.cjs +50 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
- package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
- package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
- package/dist/chunk-ZSKSXOCZ.js +139 -0
- package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
- package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
- package/dist/device-agent/index.cjs +973 -44
- package/dist/device-agent/index.d.cts +339 -3
- package/dist/device-agent/index.d.ts +339 -3
- package/dist/device-agent/index.js +940 -11
- package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
- package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
- package/dist/index.cjs +419 -937
- package/dist/index.d.cts +315 -32
- package/dist/index.d.ts +315 -32
- package/dist/index.js +423 -941
- package/dist/plugin.cjs +11 -13
- package/dist/plugin.d.cts +15 -1
- package/dist/plugin.d.ts +15 -1
- package/dist/plugin.js +7 -9
- package/dist/realtime/index.cjs +31 -0
- package/dist/realtime/index.d.cts +15 -0
- package/dist/realtime/index.d.ts +15 -0
- package/dist/realtime/index.js +31 -0
- package/dist/ui/index.cjs +1297 -0
- package/dist/ui/index.d.cts +493 -0
- package/dist/ui/index.d.ts +493 -0
- package/dist/ui/index.js +1297 -0
- package/dist/version.cjs +2 -2
- package/dist/version.js +1 -1
- package/package.json +31 -15
- package/src/advanced.ts +11 -9
- package/src/auto-resume.tsx +259 -0
- package/src/camera-controls.ts +99 -0
- package/src/camera-grant-provider.ts +392 -0
- package/src/camera-settings.ts +133 -0
- package/src/contracts.ts +73 -0
- package/src/device-agent/agent.ts +262 -9
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +11 -0
- package/src/device-agent/convex-beat-transport.ts +140 -0
- package/src/device-agent/convex-command-subscription.ts +101 -0
- package/src/device-agent/convex-control-channel.ts +7 -1
- package/src/device-agent/http-transport.ts +22 -1
- package/src/device-agent/index.ts +22 -0
- package/src/device-agent/mqtt-channel.ts +379 -0
- package/src/device-agent/mqtt-codec.ts +347 -0
- package/src/device-agent/tournament.ts +352 -0
- package/src/engine.ts +580 -15
- package/src/http-session-provider.ts +21 -189
- package/src/index.ts +84 -5
- package/src/local-video-quality.ts +43 -0
- package/src/native-runtime.ts +234 -78
- package/src/plugin.ts +19 -6
- package/src/provider.tsx +94 -18
- package/src/realtime/index.ts +56 -0
- package/src/resume-policy.ts +85 -0
- package/src/resume.ts +132 -0
- package/src/rtmp-room-adapter.ts +164 -12
- package/src/session-grant.ts +256 -0
- package/src/ui/camera-screen.tsx +484 -0
- package/src/ui/controls-sheet.tsx +445 -0
- package/src/ui/copy.ts +326 -0
- package/src/ui/helpers.ts +105 -0
- package/src/ui/hooks.ts +59 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/preview-view.tsx +39 -0
- package/src/ui/primitives.tsx +304 -0
- package/src/uplink-check.ts +173 -0
- package/dist/advanced-BXDrDl1h.d.ts +0 -316
- package/dist/advanced-BdAa2Erv.d.cts +0 -316
- package/src/livekit-room-adapter.ts +0 -390
- package/src/livestream-screen.tsx +0 -1002
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import React, { useState, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
4
|
+
import { PickleballAutoPrepare } from "../auto-resume";
|
|
5
|
+
import { useCameraSettings, type CameraSettingsStore } from "../camera-settings";
|
|
6
|
+
import { useLivestream } from "../provider";
|
|
7
|
+
import { CameraControlsSheet, type CameraControlsSheetProps } from "./controls-sheet";
|
|
8
|
+
import { uiCopy, type UiLocale } from "./copy";
|
|
9
|
+
import {
|
|
10
|
+
formatElapsed,
|
|
11
|
+
isRtmpPreview,
|
|
12
|
+
shortSessionId,
|
|
13
|
+
statusPillFor,
|
|
14
|
+
type StatusPill as StatusPillValue,
|
|
15
|
+
} from "./helpers";
|
|
16
|
+
import { useAntiFlicker, useThermalDim } from "./hooks";
|
|
17
|
+
import { RtmpPreview } from "./preview-view";
|
|
18
|
+
import { LensRow, StatusPill, uiColors } from "./primitives";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Màn hình camera dựng sẵn — bố cục của Picklive Camera (preview full-bleed,
|
|
22
|
+
* pill + tên/id phiên, hint, hàng lens, dòng info, •••, tối màn hình tiết kiệm
|
|
23
|
+
* nhiệt) cộng nút chính cho chế độ casual.
|
|
24
|
+
*
|
|
25
|
+
* Màn hình KHÔNG tự xin grant: casual thì `onGoLive` của app xin grant từ
|
|
26
|
+
* backend đối tác rồi gọi `live.start(input, {autoPublish:true})`; chế độ sân
|
|
27
|
+
* thì app arm bằng useStandbySession và trọng tài lật live qua match-start.
|
|
28
|
+
* Mọi ô chữ đều ghi đè được bằng prop để app fleet/đối tác chèn trạng thái
|
|
29
|
+
* của riêng mình (CHỜ GHÉP, tên máy, mã ghép…).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type CameraScreenMode = "casual" | "court";
|
|
33
|
+
|
|
34
|
+
export interface CameraScreenSheetRenderProps {
|
|
35
|
+
visible: boolean;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PickleballCameraScreenProps {
|
|
40
|
+
/** casual (mặc định): user bấm phát/dừng trên máy; court: đứng chờ lệnh server. */
|
|
41
|
+
mode?: CameraScreenMode;
|
|
42
|
+
/** Store cấu hình camera — nuôi dòng info + sheet ••• + chống chớp đèn. */
|
|
43
|
+
settings?: CameraSettingsStore;
|
|
44
|
+
/** Nút chính (Bắt đầu phát / Phát ngay / Dừng). Mặc định: bật ở casual, tắt ở court. */
|
|
45
|
+
showGoLiveButton?: boolean;
|
|
46
|
+
/** Casual, ở preview: app xin grant rồi start(autoPublish). Không có → ẩn nút ở preview. */
|
|
47
|
+
onGoLive?: () => void | Promise<void>;
|
|
48
|
+
/** Mặc định live.stop("user"). */
|
|
49
|
+
onStop?: () => void | Promise<void>;
|
|
50
|
+
/** Tiêu đề phiên (SDK không giữ tiêu đề trong snapshot). */
|
|
51
|
+
title?: string;
|
|
52
|
+
/** Nhãn sân cho hint chế độ court. */
|
|
53
|
+
courtLabel?: string;
|
|
54
|
+
/** Ghi đè pill (null = ẩn; undefined = mặc định theo state). */
|
|
55
|
+
statusPill?: StatusPillValue | null;
|
|
56
|
+
/** Ghi đè dòng hint (null = ẩn; undefined = mặc định theo state/mode). */
|
|
57
|
+
statusHint?: string | null;
|
|
58
|
+
/** Màu chấm trước hint (app fleet: xanh khi kênh điều khiển đã mở). */
|
|
59
|
+
statusHintDotColor?: string;
|
|
60
|
+
/** Góc trên phải, cạnh nút ••• (fleet: chip tên máy). */
|
|
61
|
+
renderTopRight?: () => ReactNode;
|
|
62
|
+
/** Dưới dòng info (fleet: badge OTA). */
|
|
63
|
+
renderBottom?: () => ReactNode;
|
|
64
|
+
/** Giữa màn hình (fleet: thẻ mã ghép). */
|
|
65
|
+
children?: ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* Sheet •••: false = không có nút; bỏ trống = CameraControlsSheet mặc định
|
|
68
|
+
* (cần `settings`); hàm = app dựng sheet của mình với {visible,onClose}.
|
|
69
|
+
*/
|
|
70
|
+
controls?: false | ((sheet: CameraScreenSheetRenderProps) => ReactNode);
|
|
71
|
+
/** Props chuyển cho CameraControlsSheet mặc định (sections/uplink/footer/layout). */
|
|
72
|
+
controlsProps?: Omit<CameraControlsSheetProps, "visible" | "onClose" | "settings">;
|
|
73
|
+
/** Hàng chọn lens (mặc định bật). */
|
|
74
|
+
lensRow?: boolean;
|
|
75
|
+
/** Dòng info dưới lens (null = ẩn; mặc định "720p • 30 fps • H.264" từ settings). */
|
|
76
|
+
infoLine?: string | null;
|
|
77
|
+
/** Tự mở camera khi idle/ended (mặc định bật). Tắt nếu app tự lo (fleet có chế độ nghỉ). */
|
|
78
|
+
autoPrepare?: boolean;
|
|
79
|
+
captureOrientation?: "portrait" | "landscape";
|
|
80
|
+
/** Tối màn hình sau chừng này ms ở LIVE; null = tắt. Mặc định 30s. */
|
|
81
|
+
thermalDimAfterMs?: number | null;
|
|
82
|
+
/** Dòng phụ trên màn tối (fleet: tên máy). */
|
|
83
|
+
thermalDimCaption?: string;
|
|
84
|
+
locale?: UiLocale;
|
|
85
|
+
style?: StyleProp<ViewStyle>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type MainAction = {
|
|
89
|
+
label: string;
|
|
90
|
+
onPress: () => void;
|
|
91
|
+
disabled?: boolean;
|
|
92
|
+
tone: "go" | "stop" | "muted";
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function PickleballCameraScreen(props: PickleballCameraScreenProps) {
|
|
96
|
+
const {
|
|
97
|
+
mode = "casual",
|
|
98
|
+
settings,
|
|
99
|
+
onGoLive,
|
|
100
|
+
onStop,
|
|
101
|
+
title,
|
|
102
|
+
courtLabel,
|
|
103
|
+
renderTopRight,
|
|
104
|
+
renderBottom,
|
|
105
|
+
children,
|
|
106
|
+
controls,
|
|
107
|
+
controlsProps,
|
|
108
|
+
lensRow = true,
|
|
109
|
+
autoPrepare = true,
|
|
110
|
+
captureOrientation,
|
|
111
|
+
thermalDimAfterMs = 30_000,
|
|
112
|
+
thermalDimCaption,
|
|
113
|
+
style,
|
|
114
|
+
} = props;
|
|
115
|
+
const live = useLivestream();
|
|
116
|
+
const locale = props.locale ?? live.locale;
|
|
117
|
+
const copy = uiCopy[locale];
|
|
118
|
+
const config = useCameraSettings(settings ?? NO_SETTINGS);
|
|
119
|
+
useAntiFlicker(settings ? config.antiFlicker : undefined);
|
|
120
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
121
|
+
const [confirmingStop, setConfirmingStop] = useState(false);
|
|
122
|
+
const [busy, setBusy] = useState(false);
|
|
123
|
+
const [thermalDimmed, setThermalDimmed] = useThermalDim(live.state, thermalDimAfterMs);
|
|
124
|
+
|
|
125
|
+
const showGoLiveButton = props.showGoLiveButton ?? mode === "casual";
|
|
126
|
+
const courtBound = mode === "court";
|
|
127
|
+
const paused = live.state === "standby" && live.elapsedMs > 0;
|
|
128
|
+
|
|
129
|
+
const pill =
|
|
130
|
+
props.statusPill === undefined
|
|
131
|
+
? statusPillFor(live.state, { elapsedMs: live.elapsedMs, courtBound, locale })
|
|
132
|
+
: props.statusPill;
|
|
133
|
+
const pillLabel =
|
|
134
|
+
pill && live.state === "live" && props.statusPill === undefined
|
|
135
|
+
? `${pill.label} ${formatElapsed(live.elapsedMs)}`
|
|
136
|
+
: pill?.label;
|
|
137
|
+
|
|
138
|
+
const hint = props.statusHint === undefined ? defaultHint() : props.statusHint;
|
|
139
|
+
|
|
140
|
+
function defaultHint(): string | null {
|
|
141
|
+
switch (live.state) {
|
|
142
|
+
case "preview":
|
|
143
|
+
return courtBound ? copy.hint.courtPreview : onGoLive ? copy.hint.casualPreview : null;
|
|
144
|
+
case "standby":
|
|
145
|
+
if (paused) return copy.hint.paused;
|
|
146
|
+
if (courtBound) {
|
|
147
|
+
return courtLabel ? copy.hint.courtStandby(courtLabel) : copy.hint.courtStandbyNoCourt;
|
|
148
|
+
}
|
|
149
|
+
return copy.hint.casualStandby;
|
|
150
|
+
case "connecting":
|
|
151
|
+
return copy.hint.connecting;
|
|
152
|
+
case "reconnecting":
|
|
153
|
+
return copy.hint.reconnecting;
|
|
154
|
+
case "ending":
|
|
155
|
+
return copy.hint.ending;
|
|
156
|
+
case "error":
|
|
157
|
+
return live.error ? copy.errors.remediation[live.error.code] : copy.hint.errorFallback;
|
|
158
|
+
default:
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const errorTitle = live.error ? copy.errors.title[live.error.code] : null;
|
|
163
|
+
|
|
164
|
+
async function run(action: () => void | Promise<void>) {
|
|
165
|
+
setBusy(true);
|
|
166
|
+
try {
|
|
167
|
+
await action();
|
|
168
|
+
} catch {
|
|
169
|
+
// Lỗi đã vào snapshot (state "error" + message) — nút Thử lại lo tiếp.
|
|
170
|
+
} finally {
|
|
171
|
+
setBusy(false);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const mainAction = ((): MainAction | null => {
|
|
176
|
+
if (!showGoLiveButton) return null;
|
|
177
|
+
if (busy) return { label: copy.button.connecting, onPress: () => undefined, disabled: true, tone: "muted" };
|
|
178
|
+
switch (live.state) {
|
|
179
|
+
case "preview":
|
|
180
|
+
return onGoLive
|
|
181
|
+
? { label: copy.button.goLive, onPress: () => void run(onGoLive), tone: "go" }
|
|
182
|
+
: null;
|
|
183
|
+
case "standby":
|
|
184
|
+
return {
|
|
185
|
+
label: paused ? copy.button.resume : copy.button.publishNow,
|
|
186
|
+
onPress: () => void run(() => live.startPublishing()),
|
|
187
|
+
tone: "go",
|
|
188
|
+
};
|
|
189
|
+
case "live":
|
|
190
|
+
return confirmingStop
|
|
191
|
+
? {
|
|
192
|
+
label: copy.button.confirmStop,
|
|
193
|
+
onPress: () => {
|
|
194
|
+
setConfirmingStop(false);
|
|
195
|
+
void run(onStop ?? (() => live.stop("user")));
|
|
196
|
+
},
|
|
197
|
+
tone: "stop",
|
|
198
|
+
}
|
|
199
|
+
: { label: copy.button.stop, onPress: () => setConfirmingStop(true), tone: "stop" };
|
|
200
|
+
case "error":
|
|
201
|
+
return {
|
|
202
|
+
label: copy.button.retry,
|
|
203
|
+
onPress: () =>
|
|
204
|
+
void run(() => (live.sessionId ? live.retry() : live.prepare())),
|
|
205
|
+
tone: "go",
|
|
206
|
+
};
|
|
207
|
+
case "connecting":
|
|
208
|
+
case "reconnecting":
|
|
209
|
+
return { label: copy.button.connecting, onPress: () => undefined, disabled: true, tone: "muted" };
|
|
210
|
+
case "ending":
|
|
211
|
+
return { label: copy.button.ending, onPress: () => undefined, disabled: true, tone: "muted" };
|
|
212
|
+
default:
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
})();
|
|
216
|
+
|
|
217
|
+
const infoLine =
|
|
218
|
+
props.infoLine === undefined
|
|
219
|
+
? settings
|
|
220
|
+
? `${config.resolution}p • ${config.fps} fps • H.264`
|
|
221
|
+
: null
|
|
222
|
+
: props.infoLine;
|
|
223
|
+
|
|
224
|
+
const showPreview = isRtmpPreview(live.previewTrack);
|
|
225
|
+
const sheetEnabled = controls !== false && (controls !== undefined || settings !== undefined);
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<View style={[styles.root, style]}>
|
|
229
|
+
{autoPrepare ? (
|
|
230
|
+
<PickleballAutoPrepare captureOrientation={captureOrientation} />
|
|
231
|
+
) : null}
|
|
232
|
+
{showPreview ? (
|
|
233
|
+
<RtmpPreview style={StyleSheet.absoluteFill} />
|
|
234
|
+
) : (
|
|
235
|
+
<View style={[StyleSheet.absoluteFill, styles.center]}>
|
|
236
|
+
{live.state === "error" ? (
|
|
237
|
+
<>
|
|
238
|
+
<Text style={styles.errorTitle} accessibilityRole="alert">
|
|
239
|
+
{errorTitle ?? copy.hint.cameraError}
|
|
240
|
+
</Text>
|
|
241
|
+
{live.error ? (
|
|
242
|
+
<Text style={styles.errorDetail} selectable>
|
|
243
|
+
{copy.errors.technicalDetail}: {live.error.message}
|
|
244
|
+
</Text>
|
|
245
|
+
) : null}
|
|
246
|
+
</>
|
|
247
|
+
) : (
|
|
248
|
+
<Text style={styles.mutedText}>{copy.hint.cameraOpening}</Text>
|
|
249
|
+
)}
|
|
250
|
+
</View>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
<SafeAreaView style={styles.overlay} pointerEvents="box-none">
|
|
254
|
+
<View style={styles.topArea} pointerEvents="box-none">
|
|
255
|
+
<View style={styles.topRow} pointerEvents="box-none">
|
|
256
|
+
<View style={styles.statusCard} pointerEvents="none">
|
|
257
|
+
{pill && pillLabel ? <StatusPill label={pillLabel} color={pill.color} /> : null}
|
|
258
|
+
{live.sessionId ? (
|
|
259
|
+
<View style={styles.sessionInfo}>
|
|
260
|
+
{title ? (
|
|
261
|
+
<Text style={styles.sessionTitleText} numberOfLines={1}>
|
|
262
|
+
{title}
|
|
263
|
+
</Text>
|
|
264
|
+
) : null}
|
|
265
|
+
<Text style={styles.sessionIdText} numberOfLines={1}>
|
|
266
|
+
{shortSessionId(live.sessionId)}
|
|
267
|
+
</Text>
|
|
268
|
+
</View>
|
|
269
|
+
) : null}
|
|
270
|
+
</View>
|
|
271
|
+
{renderTopRight?.()}
|
|
272
|
+
{sheetEnabled ? (
|
|
273
|
+
<Pressable
|
|
274
|
+
onPress={() => setSheetOpen(true)}
|
|
275
|
+
style={styles.moreButton}
|
|
276
|
+
accessibilityRole="button"
|
|
277
|
+
accessibilityLabel={copy.sheet.title}
|
|
278
|
+
>
|
|
279
|
+
<Text style={styles.moreButtonText}>•••</Text>
|
|
280
|
+
</Pressable>
|
|
281
|
+
) : null}
|
|
282
|
+
</View>
|
|
283
|
+
{/* Hint ẩn khi LIVE — pill đã nói đủ, đỡ một dòng thừa */}
|
|
284
|
+
{hint && live.state !== "live" ? (
|
|
285
|
+
<View style={styles.stepHintChip} pointerEvents="none">
|
|
286
|
+
<View
|
|
287
|
+
style={[
|
|
288
|
+
styles.stepHintDot,
|
|
289
|
+
{ backgroundColor: props.statusHintDotColor ?? uiColors.textDim },
|
|
290
|
+
]}
|
|
291
|
+
/>
|
|
292
|
+
<Text style={styles.stepHintText}>{hint}</Text>
|
|
293
|
+
</View>
|
|
294
|
+
) : null}
|
|
295
|
+
</View>
|
|
296
|
+
|
|
297
|
+
{children ? (
|
|
298
|
+
<View style={styles.centerSlot} pointerEvents="box-none">
|
|
299
|
+
{children}
|
|
300
|
+
</View>
|
|
301
|
+
) : null}
|
|
302
|
+
|
|
303
|
+
<View style={styles.bottomArea} pointerEvents="box-none">
|
|
304
|
+
{mainAction ? (
|
|
305
|
+
<View style={styles.mainRow}>
|
|
306
|
+
{confirmingStop && live.state === "live" ? (
|
|
307
|
+
<Pressable
|
|
308
|
+
accessibilityRole="button"
|
|
309
|
+
onPress={() => setConfirmingStop(false)}
|
|
310
|
+
style={[styles.mainButton, styles.mainMuted]}
|
|
311
|
+
>
|
|
312
|
+
<Text style={styles.mainButtonText}>{copy.button.cancel}</Text>
|
|
313
|
+
</Pressable>
|
|
314
|
+
) : null}
|
|
315
|
+
<Pressable
|
|
316
|
+
accessibilityRole="button"
|
|
317
|
+
accessibilityState={{ disabled: mainAction.disabled === true }}
|
|
318
|
+
disabled={mainAction.disabled}
|
|
319
|
+
onPress={mainAction.onPress}
|
|
320
|
+
style={[
|
|
321
|
+
styles.mainButton,
|
|
322
|
+
mainAction.tone === "go" && styles.mainGo,
|
|
323
|
+
mainAction.tone === "stop" && styles.mainStop,
|
|
324
|
+
mainAction.tone === "muted" && styles.mainMuted,
|
|
325
|
+
]}
|
|
326
|
+
>
|
|
327
|
+
<Text
|
|
328
|
+
style={[styles.mainButtonText, mainAction.tone === "go" && styles.mainGoText]}
|
|
329
|
+
>
|
|
330
|
+
{mainAction.label}
|
|
331
|
+
</Text>
|
|
332
|
+
</Pressable>
|
|
333
|
+
</View>
|
|
334
|
+
) : null}
|
|
335
|
+
{lensRow ? (
|
|
336
|
+
<LensRow
|
|
337
|
+
cameras={live.cameras}
|
|
338
|
+
selectedCameraId={live.selectedCameraId}
|
|
339
|
+
onSelect={(deviceId) => void live.selectCamera(deviceId).catch(() => undefined)}
|
|
340
|
+
locale={locale}
|
|
341
|
+
/>
|
|
342
|
+
) : null}
|
|
343
|
+
{infoLine ? <Text style={styles.infoLine}>{infoLine}</Text> : null}
|
|
344
|
+
{renderBottom?.()}
|
|
345
|
+
</View>
|
|
346
|
+
</SafeAreaView>
|
|
347
|
+
|
|
348
|
+
{sheetEnabled
|
|
349
|
+
? controls
|
|
350
|
+
? controls({ visible: sheetOpen, onClose: () => setSheetOpen(false) })
|
|
351
|
+
: settings
|
|
352
|
+
? (
|
|
353
|
+
<CameraControlsSheet
|
|
354
|
+
{...controlsProps}
|
|
355
|
+
visible={sheetOpen}
|
|
356
|
+
onClose={() => setSheetOpen(false)}
|
|
357
|
+
settings={settings}
|
|
358
|
+
locale={controlsProps?.locale ?? locale}
|
|
359
|
+
/>
|
|
360
|
+
)
|
|
361
|
+
: null
|
|
362
|
+
: null}
|
|
363
|
+
|
|
364
|
+
{thermalDimmed ? (
|
|
365
|
+
<Pressable
|
|
366
|
+
onPress={() => setThermalDimmed(false)}
|
|
367
|
+
style={styles.thermalOverlay}
|
|
368
|
+
accessibilityRole="button"
|
|
369
|
+
accessibilityLabel={copy.thermal.subtitle}
|
|
370
|
+
>
|
|
371
|
+
<View style={styles.thermalCard}>
|
|
372
|
+
<StatusPill label={`${copy.pill.live} ${formatElapsed(live.elapsedMs)}`} color="#ef4444" />
|
|
373
|
+
<Text style={styles.thermalTitle}>{copy.thermal.title}</Text>
|
|
374
|
+
{thermalDimCaption ? (
|
|
375
|
+
<Text style={styles.thermalCaption}>{thermalDimCaption}</Text>
|
|
376
|
+
) : null}
|
|
377
|
+
<Text style={styles.thermalSubtitle}>{copy.thermal.subtitle}</Text>
|
|
378
|
+
</View>
|
|
379
|
+
</Pressable>
|
|
380
|
+
) : null}
|
|
381
|
+
</View>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Store rỗng cho màn hình không có settings — hook vẫn phải gọi cùng số lần.
|
|
387
|
+
* `get` trả CÙNG MỘT object: useSyncExternalStore so sánh theo tham chiếu, trả
|
|
388
|
+
* object mới mỗi lần là React coi store đổi liên tục → "Maximum update depth".
|
|
389
|
+
*/
|
|
390
|
+
const NO_SETTINGS_VALUE = Object.freeze({
|
|
391
|
+
resolution: 720 as const,
|
|
392
|
+
fps: 30 as const,
|
|
393
|
+
antiFlicker: "50hz" as const,
|
|
394
|
+
cameraId: null,
|
|
395
|
+
});
|
|
396
|
+
const NO_SETTINGS: CameraSettingsStore = {
|
|
397
|
+
key: "none",
|
|
398
|
+
get: () => NO_SETTINGS_VALUE,
|
|
399
|
+
set: () => undefined,
|
|
400
|
+
subscribe: () => () => undefined,
|
|
401
|
+
restore: async () => undefined,
|
|
402
|
+
isRestored: () => true,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const styles = StyleSheet.create({
|
|
406
|
+
root: { flex: 1, backgroundColor: uiColors.ink },
|
|
407
|
+
center: { alignItems: "center", justifyContent: "center", padding: 24 },
|
|
408
|
+
overlay: { flex: 1, justifyContent: "space-between" },
|
|
409
|
+
topArea: { gap: 8 },
|
|
410
|
+
topRow: {
|
|
411
|
+
flexDirection: "row",
|
|
412
|
+
justifyContent: "space-between",
|
|
413
|
+
alignItems: "center",
|
|
414
|
+
gap: 8,
|
|
415
|
+
paddingHorizontal: 16,
|
|
416
|
+
paddingTop: 8,
|
|
417
|
+
},
|
|
418
|
+
statusCard: {
|
|
419
|
+
flexDirection: "row",
|
|
420
|
+
alignItems: "center",
|
|
421
|
+
gap: 8,
|
|
422
|
+
flexShrink: 1,
|
|
423
|
+
minWidth: 0,
|
|
424
|
+
},
|
|
425
|
+
stepHintChip: {
|
|
426
|
+
flexDirection: "row",
|
|
427
|
+
alignItems: "center",
|
|
428
|
+
gap: 6,
|
|
429
|
+
alignSelf: "flex-start",
|
|
430
|
+
marginLeft: 16,
|
|
431
|
+
backgroundColor: uiColors.chip,
|
|
432
|
+
borderRadius: 999,
|
|
433
|
+
paddingHorizontal: 10,
|
|
434
|
+
paddingVertical: 5,
|
|
435
|
+
maxWidth: 340,
|
|
436
|
+
},
|
|
437
|
+
stepHintDot: { width: 6, height: 6, borderRadius: 3 },
|
|
438
|
+
stepHintText: { color: uiColors.textMuted, fontSize: 11 },
|
|
439
|
+
sessionInfo: { flexShrink: 1, minWidth: 0 },
|
|
440
|
+
sessionTitleText: { color: uiColors.text, fontSize: 11, fontWeight: "700" },
|
|
441
|
+
sessionIdText: { color: uiColors.textMuted, fontSize: 9, fontVariant: ["tabular-nums"] },
|
|
442
|
+
moreButton: {
|
|
443
|
+
borderRadius: 999,
|
|
444
|
+
backgroundColor: uiColors.chip,
|
|
445
|
+
paddingHorizontal: 12,
|
|
446
|
+
paddingVertical: 7,
|
|
447
|
+
},
|
|
448
|
+
moreButtonText: { color: uiColors.text, fontSize: 13, fontWeight: "700" },
|
|
449
|
+
centerSlot: { alignItems: "center", justifyContent: "center" },
|
|
450
|
+
bottomArea: { alignItems: "center", gap: 10, paddingBottom: 12 },
|
|
451
|
+
mainRow: { flexDirection: "row", gap: 10 },
|
|
452
|
+
mainButton: {
|
|
453
|
+
borderRadius: 999,
|
|
454
|
+
paddingHorizontal: 26,
|
|
455
|
+
paddingVertical: 12,
|
|
456
|
+
minWidth: 150,
|
|
457
|
+
alignItems: "center",
|
|
458
|
+
backgroundColor: uiColors.chip,
|
|
459
|
+
},
|
|
460
|
+
mainGo: { backgroundColor: "#b4ff39" },
|
|
461
|
+
mainStop: { backgroundColor: "#ef4444" },
|
|
462
|
+
mainMuted: { backgroundColor: uiColors.chip },
|
|
463
|
+
mainButtonText: { color: uiColors.text, fontSize: 15, fontWeight: "800", letterSpacing: 0.3 },
|
|
464
|
+
mainGoText: { color: uiColors.ink },
|
|
465
|
+
infoLine: { color: uiColors.textDim, fontSize: 11, fontVariant: ["tabular-nums"] },
|
|
466
|
+
mutedText: { color: uiColors.textDim, fontSize: 14, textAlign: "center" },
|
|
467
|
+
errorTitle: { color: uiColors.text, fontSize: 16, fontWeight: "800", textAlign: "center" },
|
|
468
|
+
errorDetail: { color: uiColors.textDim, fontSize: 11, textAlign: "center", marginTop: 8 },
|
|
469
|
+
thermalOverlay: {
|
|
470
|
+
position: "absolute",
|
|
471
|
+
top: 0,
|
|
472
|
+
left: 0,
|
|
473
|
+
right: 0,
|
|
474
|
+
bottom: 0,
|
|
475
|
+
backgroundColor: "rgba(0,0,0,0.92)",
|
|
476
|
+
alignItems: "center",
|
|
477
|
+
justifyContent: "center",
|
|
478
|
+
zIndex: 99,
|
|
479
|
+
},
|
|
480
|
+
thermalCard: { alignItems: "center", gap: 12, padding: 24 },
|
|
481
|
+
thermalTitle: { color: uiColors.text, fontSize: 18, fontWeight: "700" },
|
|
482
|
+
thermalCaption: { color: uiColors.textMuted, fontSize: 16, fontWeight: "800", letterSpacing: 0.5 },
|
|
483
|
+
thermalSubtitle: { color: uiColors.textDim, fontSize: 13 },
|
|
484
|
+
});
|