@pickleball/expo-sdk 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +155 -101
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +14 -2
  6. package/dist/advanced.d.cts +2 -2
  7. package/dist/advanced.d.ts +2 -2
  8. package/dist/advanced.js +17 -5
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-62YV3PCD.cjs +139 -0
  12. package/dist/chunk-6F3HN6US.js +50 -0
  13. package/dist/chunk-6YE5BL3A.cjs +50 -0
  14. package/dist/chunk-7Z7TN5N3.js +318 -0
  15. package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
  16. package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
  17. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  18. package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
  19. package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
  20. package/dist/chunk-ZSKSXOCZ.js +139 -0
  21. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  22. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  23. package/dist/device-agent/index.cjs +973 -44
  24. package/dist/device-agent/index.d.cts +339 -3
  25. package/dist/device-agent/index.d.ts +339 -3
  26. package/dist/device-agent/index.js +940 -11
  27. package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
  28. package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
  29. package/dist/index.cjs +419 -937
  30. package/dist/index.d.cts +315 -32
  31. package/dist/index.d.ts +315 -32
  32. package/dist/index.js +423 -941
  33. package/dist/plugin.cjs +11 -13
  34. package/dist/plugin.d.cts +15 -1
  35. package/dist/plugin.d.ts +15 -1
  36. package/dist/plugin.js +7 -9
  37. package/dist/realtime/index.cjs +31 -0
  38. package/dist/realtime/index.d.cts +15 -0
  39. package/dist/realtime/index.d.ts +15 -0
  40. package/dist/realtime/index.js +31 -0
  41. package/dist/ui/index.cjs +1297 -0
  42. package/dist/ui/index.d.cts +493 -0
  43. package/dist/ui/index.d.ts +493 -0
  44. package/dist/ui/index.js +1297 -0
  45. package/dist/version.cjs +2 -2
  46. package/dist/version.js +1 -1
  47. package/package.json +31 -15
  48. package/src/advanced.ts +11 -9
  49. package/src/auto-resume.tsx +259 -0
  50. package/src/camera-controls.ts +99 -0
  51. package/src/camera-grant-provider.ts +392 -0
  52. package/src/camera-settings.ts +133 -0
  53. package/src/contracts.ts +73 -0
  54. package/src/device-agent/agent.ts +262 -9
  55. package/src/device-agent/beat-policy.ts +46 -0
  56. package/src/device-agent/component.tsx +11 -0
  57. package/src/device-agent/convex-beat-transport.ts +140 -0
  58. package/src/device-agent/convex-command-subscription.ts +101 -0
  59. package/src/device-agent/convex-control-channel.ts +7 -1
  60. package/src/device-agent/http-transport.ts +22 -1
  61. package/src/device-agent/index.ts +22 -0
  62. package/src/device-agent/mqtt-channel.ts +379 -0
  63. package/src/device-agent/mqtt-codec.ts +347 -0
  64. package/src/device-agent/tournament.ts +352 -0
  65. package/src/engine.ts +580 -15
  66. package/src/http-session-provider.ts +21 -189
  67. package/src/index.ts +84 -5
  68. package/src/local-video-quality.ts +43 -0
  69. package/src/native-runtime.ts +234 -78
  70. package/src/plugin.ts +19 -6
  71. package/src/provider.tsx +94 -18
  72. package/src/realtime/index.ts +56 -0
  73. package/src/resume-policy.ts +85 -0
  74. package/src/resume.ts +132 -0
  75. package/src/rtmp-room-adapter.ts +164 -12
  76. package/src/session-grant.ts +256 -0
  77. package/src/ui/camera-screen.tsx +484 -0
  78. package/src/ui/controls-sheet.tsx +445 -0
  79. package/src/ui/copy.ts +326 -0
  80. package/src/ui/helpers.ts +105 -0
  81. package/src/ui/hooks.ts +59 -0
  82. package/src/ui/index.ts +42 -0
  83. package/src/ui/preview-view.tsx +39 -0
  84. package/src/ui/primitives.tsx +304 -0
  85. package/src/uplink-check.ts +173 -0
  86. package/dist/advanced-BXDrDl1h.d.ts +0 -316
  87. package/dist/advanced-BdAa2Erv.d.cts +0 -316
  88. package/src/livekit-room-adapter.ts +0 -390
  89. package/src/livestream-screen.tsx +0 -1002
@@ -0,0 +1,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
+ });