@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
package/dist/index.js CHANGED
@@ -1,5 +1,51 @@
1
1
  import {
2
+ PICKLEBALL_EXPO_SDK_VERSION
3
+ } from "./chunk-XL4CISVQ.js";
4
+ import {
5
+ createJsonPoster,
6
+ httpStatusOf,
7
+ isBootstrap,
8
+ isCameraGrant,
9
+ isFiniteNumber,
10
+ isRecord,
11
+ isSessionGrant,
12
+ isSessionRefresh,
13
+ isSessionStatus,
14
+ isTelemetryCredentials,
15
+ joinUrl,
16
+ normalizeBaseUrl,
17
+ sessionApiBaseFromTelemetryEndpoint
18
+ } from "./chunk-ZSKSXOCZ.js";
19
+ import {
20
+ CAMERA_SETTINGS_STORAGE_KEY,
21
+ DEFAULT_CAMERA_SETTINGS,
22
+ PickleballAutoPrepare,
23
+ PickleballAutoResume,
24
+ RESUME_RETRY_DELAYS_MS,
25
+ applyAntiFlicker,
26
+ cameraSettingsKey,
27
+ classifyResumeStatus,
28
+ createCameraSettingsStore,
29
+ getCameraCapabilities,
30
+ parseCameraSettings,
31
+ requestNativePermissions,
32
+ resumeBlocksArm,
33
+ resumeExternalSessionId,
34
+ resumeGrantMatches,
35
+ resumeRetryDelayMs,
36
+ rtmpRoomFactoryFor,
37
+ setExposureBias,
38
+ setExposureLocked,
39
+ setFocusLocked,
40
+ setTorch,
41
+ setZoom,
42
+ useCameraSettings,
43
+ useStandbySession
44
+ } from "./chunk-7Z7TN5N3.js";
45
+ import {
46
+ AUDIO_BITRATE_BPS,
2
47
  ConsentRequiredError,
48
+ DEFAULT_UPLOAD_URL,
3
49
  ExpoSdkConfigurationError,
4
50
  ExpoSdkTimeoutError,
5
51
  InvalidResponseError,
@@ -15,874 +61,26 @@ import {
15
61
  UnknownSdkError,
16
62
  UnsupportedRuntimeError,
17
63
  UnsupportedSdkError,
64
+ VIDEO_BITRATE_BPS,
18
65
  compareSemver,
19
66
  createDefaultStorage,
67
+ createRtmpRoomFactory,
68
+ createRtmpRuntime,
20
69
  defaultLivestreamTheme,
21
70
  errorFromCode,
22
71
  isSdkRemoteConfig,
23
72
  isSdkVersionSupported,
24
73
  isSessionGoneError,
25
74
  normalizeError,
26
- useLivestream
27
- } from "./chunk-FODE2W2H.js";
28
- import {
29
- PICKLEBALL_EXPO_SDK_VERSION
30
- } from "./chunk-6JOMESNT.js";
75
+ requiredMbpsFor,
76
+ runUplinkCheck,
77
+ useLivestream,
78
+ verdictFor
79
+ } from "./chunk-XGP2MN5R.js";
31
80
  import {
32
81
  __require
33
82
  } from "./chunk-KFQGP6VL.js";
34
83
 
35
- // src/livestream-screen.tsx
36
- import React, { useEffect, useState } from "react";
37
- import {
38
- Pressable,
39
- ScrollView,
40
- Text,
41
- View,
42
- useWindowDimensions
43
- } from "react-native";
44
- import { useSafeAreaInsets } from "react-native-safe-area-context";
45
-
46
- // ../shared/src/court-detection.ts
47
- var COURT_DETECTION_MAX_IMAGE_BYTES = 3 * 1024 * 1024;
48
- var KITCHEN_FAR_Y = 15 / 44;
49
- var KITCHEN_NEAR_Y = 29 / 44;
50
-
51
- // ../shared/src/index.ts
52
- var MAX_LIVE_DURATION_MS = 6 * 60 * 60 * 1e3;
53
- var EMPTY_ROOM_TIMEOUT_MS = 10 * 60 * 1e3;
54
- var STALE_SCHEDULED_MS = 48 * 60 * 60 * 1e3;
55
- var NO_PUBLISHER_TIMEOUT_MS = 5 * 60 * 1e3;
56
- function sessionShortCode(sessionId) {
57
- const AMBIGUOUS = /[01ilo]/g;
58
- const cleaned = sessionId.toLowerCase().replace(AMBIGUOUS, "");
59
- return cleaned.slice(-6).toUpperCase().padStart(6, "X");
60
- }
61
-
62
- // src/livestream-screen.tsx
63
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
64
- var copy = {
65
- vi: {
66
- states: {
67
- idle: "S\u1EB5n s\xE0ng",
68
- preparing: "\u0110ang chu\u1EA9n b\u1ECB\u2026",
69
- preview: "Xem tr\u01B0\u1EDBc",
70
- connecting: "\u0110ang k\u1EBFt n\u1ED1i\u2026",
71
- standby: "S\u1EB4N S\xC0NG PH\xC1T",
72
- live: "\u0110ANG PH\xC1T",
73
- reconnecting: "\u0110ang k\u1EBFt n\u1ED1i l\u1EA1i\u2026",
74
- ending: "\u0110ang k\u1EBFt th\xFAc\u2026",
75
- ended: "\u0110\xE3 k\u1EBFt th\xFAc",
76
- error: "\u0110\xE3 x\u1EA3y ra l\u1ED7i"
77
- },
78
- consent: "\u0110\u1ED3ng \xFD ghi h\xECnh",
79
- consentDetail: "T\xF4i \u0111\u1ED3ng \xFD phi\xEAn ph\xE1t n\xE0y \u0111\u01B0\u1EE3c ghi h\xECnh v\xE0 l\u01B0u th\xE0nh video.",
80
- start: "B\u1EAFt \u0111\u1EA7u k\u1EBFt n\u1ED1i",
81
- startStandby: "Ch\u1EBF \u0111\u1ED9 ch\u1EDD",
82
- startLive: "Ph\xE1t ngay",
83
- confirmStart: "X\xE1c nh\u1EADn b\u1EAFt \u0111\u1EA7u",
84
- afterThree: "K\u1EBFt n\u1ED1i sau 3 gi\xE2y",
85
- live: "\u0110ANG PH\xC1T",
86
- endLabel: "K\u1EBFt th\xFAc ph\xE1t",
87
- endQuestion: "K\u1EBFt th\xFAc phi\xEAn ph\xE1t?",
88
- end: "K\u1EBFt th\xFAc",
89
- cancel: "H\u1EE7y",
90
- ended: "\u0110\xE3 k\u1EBFt th\xFAc",
91
- retry: "Th\u1EED l\u1EA1i",
92
- errorDetail: "Chi ti\u1EBFt k\u1EF9 thu\u1EADt",
93
- offline: "M\u1EA5t k\u1EBFt n\u1ED1i m\u1EA1ng",
94
- flip: "\u0110\u1ED5i camera",
95
- playLabel: "B\u1EAFt \u0111\u1EA7u ph\xE1t",
96
- pauseLabel: "T\u1EA1m d\u1EEBng ph\xE1t",
97
- mic: "Micr\xF4",
98
- orientationLabel: "Khung h\xECnh",
99
- portrait: "9:16 D\u1ECDc",
100
- landscape: "16:9 Ngang",
101
- portraitHint: "C\u1EA7m m\xE1y d\u1ECDc trong su\u1ED1t bu\u1ED5i quay.",
102
- landscapeHint: "C\u1EA7m m\xE1y ngang trong su\u1ED1t bu\u1ED5i quay.",
103
- rotatePortrait: "Xoay m\xE1y d\u1ECDc theo khung 9:16 \u0111\u1EC3 b\u1EAFt \u0111\u1EA7u ph\xE1t.",
104
- rotateLandscape: "Xoay m\xE1y ngang theo khung 16:9 \u0111\u1EC3 b\u1EAFt \u0111\u1EA7u ph\xE1t.",
105
- lensLabel: "\u1ED0ng k\xEDnh",
106
- cameraLabel: "Camera",
107
- cameraFront: "Tr\u01B0\u1EDBc",
108
- cameraBack: "Sau",
109
- visibilityLabel: "Ch\u1EBF \u0111\u1ED9",
110
- visPublic: "C\xF4ng khai",
111
- visPrivate: "Ri\xEAng t\u01B0",
112
- backToPreview: "Quay l\u1EA1i m\xE0n h\xECnh ch\u1EDD"
113
- },
114
- en: {
115
- states: {
116
- idle: "Ready",
117
- preparing: "Preparing\u2026",
118
- preview: "Preview",
119
- connecting: "Connecting\u2026",
120
- standby: "READY",
121
- live: "LIVE",
122
- reconnecting: "Reconnecting\u2026",
123
- ending: "Ending\u2026",
124
- ended: "Livestream ended",
125
- error: "Something went wrong"
126
- },
127
- consent: "Recording consent",
128
- consentDetail: "I consent to recording and saving this livestream as video.",
129
- start: "Connect",
130
- startStandby: "Standby Mode",
131
- startLive: "Go Live Now",
132
- confirmStart: "Confirm start",
133
- afterThree: "Connect in 3 seconds",
134
- live: "LIVE",
135
- endLabel: "End livestream",
136
- endQuestion: "End this livestream?",
137
- end: "End",
138
- cancel: "Cancel",
139
- ended: "Livestream ended",
140
- retry: "Try again",
141
- errorDetail: "Technical details",
142
- offline: "Network offline",
143
- flip: "Flip camera",
144
- playLabel: "Start broadcasting",
145
- pauseLabel: "Pause broadcasting",
146
- mic: "Microphone",
147
- orientationLabel: "Frame",
148
- portrait: "9:16 Tall",
149
- landscape: "16:9 Wide",
150
- portraitHint: "Hold the phone upright while filming.",
151
- landscapeHint: "Hold the phone sideways while filming.",
152
- rotatePortrait: "Rotate the phone upright to match the 9:16 frame before going live.",
153
- rotateLandscape: "Rotate the phone sideways to match the 16:9 frame before going live.",
154
- lensLabel: "Lens",
155
- cameraLabel: "Camera",
156
- cameraFront: "Front",
157
- cameraBack: "Back",
158
- visibilityLabel: "Mode",
159
- visPublic: "Public",
160
- visPrivate: "Private",
161
- backToPreview: "Back to standby"
162
- }
163
- };
164
- var errorTitle = {
165
- vi: {
166
- PERMISSION_DENIED: "C\u1EA7n quy\u1EC1n Camera v\xE0 Micr\xF4",
167
- NETWORK_UNAVAILABLE: "Kh\xF4ng c\xF3 k\u1EBFt n\u1ED1i m\u1EA1ng",
168
- TOKEN_EXPIRED: "Phi\xEAn x\xE1c th\u1EF1c \u0111\xE3 h\u1EBFt h\u1EA1n",
169
- SDK_UPGRADE_REQUIRED: "C\u1EA7n c\u1EADp nh\u1EADt \u1EE9ng d\u1EE5ng",
170
- UNSUPPORTED_RUNTIME: "M\xF4i tr\u01B0\u1EDDng ch\u1EA1y kh\xF4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3",
171
- SESSION_CONFLICT: "\u0110ang c\xF3 phi\xEAn ph\xE1t kh\xE1c",
172
- RECORDING_FAILED: "Kh\xF4ng th\u1EC3 ghi h\xECnh",
173
- CONSENT_REQUIRED: "C\u1EA7n \u0111\u1ED3ng \xFD ghi h\xECnh",
174
- SDK_DISABLED: "T\xEDnh n\u0103ng ph\xE1t tr\u1EF1c ti\u1EBFp \u0111ang t\u1EAFt",
175
- CONFIGURATION_ERROR: "C\u1EA5u h\xECnh SDK ch\u01B0a \u0111\xFAng",
176
- INVALID_RESPONSE: "Ph\u1EA3n h\u1ED3i m\xE1y ch\u1EE7 kh\xF4ng h\u1EE3p l\u1EC7",
177
- TIMEOUT: "Y\xEAu c\u1EA7u qu\xE1 th\u1EDDi gian",
178
- UNKNOWN: "\u0110\xE3 x\u1EA3y ra l\u1ED7i"
179
- },
180
- en: {
181
- PERMISSION_DENIED: "Camera and Microphone access required",
182
- NETWORK_UNAVAILABLE: "No network connection",
183
- TOKEN_EXPIRED: "Authentication expired",
184
- SDK_UPGRADE_REQUIRED: "App update required",
185
- UNSUPPORTED_RUNTIME: "Unsupported runtime",
186
- SESSION_CONFLICT: "Another livestream is active",
187
- RECORDING_FAILED: "Recording failed",
188
- CONSENT_REQUIRED: "Recording consent required",
189
- SDK_DISABLED: "Livestreaming is disabled",
190
- CONFIGURATION_ERROR: "SDK configuration error",
191
- INVALID_RESPONSE: "Invalid server response",
192
- TIMEOUT: "Request timed out",
193
- UNKNOWN: "Something went wrong"
194
- }
195
- };
196
- var remediation = {
197
- vi: {
198
- PERMISSION_DENIED: "M\u1EDF C\xE0i \u0111\u1EB7t v\xE0 cho ph\xE9p Camera c\xF9ng Micr\xF4, sau \u0111\xF3 th\u1EED l\u1EA1i.",
199
- NETWORK_UNAVAILABLE: "Ki\u1EC3m tra Wi-Fi ho\u1EB7c d\u1EEF li\u1EC7u di \u0111\u1ED9ng r\u1ED3i th\u1EED l\u1EA1i.",
200
- TOKEN_EXPIRED: "Phi\xEAn x\xE1c th\u1EF1c \u0111\xE3 h\u1EBFt h\u1EA1n. H\xE3y k\u1EBFt n\u1ED1i m\u1EA1ng v\xE0 th\u1EED l\u1EA1i.",
201
- SDK_UPGRADE_REQUIRED: "C\u1EADp nh\u1EADt \u1EE9ng d\u1EE5ng l\xEAn b\u1EA3n m\u1EDBi nh\u1EA5t v\xE0 c\xE0i l\u1EA1i Development Build.",
202
- UNSUPPORTED_RUNTIME: "C\xE0i Development Build tr\xEAn iOS ho\u1EB7c Android; Expo Go v\xE0 web kh\xF4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3.",
203
- SESSION_CONFLICT: "K\u1EBFt th\xFAc phi\xEAn ph\xE1t \u0111ang ho\u1EA1t \u0111\u1ED9ng tr\u01B0\u1EDBc khi b\u1EAFt \u0111\u1EA7u phi\xEAn m\u1EDBi.",
204
- RECORDING_FAILED: "Kh\xF4ng th\u1EC3 t\u1EA1o b\u1EA3n ghi m\xE1y ch\u1EE7. H\xE3y th\u1EED l\u1EA1i ho\u1EB7c li\xEAn h\u1EC7 h\u1ED7 tr\u1EE3.",
205
- CONSENT_REQUIRED: "\u0110\u1ED3ng \xFD ghi h\xECnh tr\u01B0\u1EDBc khi b\u1EAFt \u0111\u1EA7u ph\xE1t tr\u1EF1c ti\u1EBFp.",
206
- SDK_DISABLED: "T\xEDnh n\u0103ng \u0111ang b\u1ECB t\u1EAFt cho \u1EE9ng d\u1EE5ng n\xE0y. H\xE3y li\xEAn h\u1EC7 qu\u1EA3n tr\u1ECB vi\xEAn.",
207
- CONFIGURATION_ERROR: "Ki\u1EC3m tra c\u1EA5u h\xECnh Provider v\xE0 config plugin, sau \u0111\xF3 rebuild \u1EE9ng d\u1EE5ng.",
208
- INVALID_RESPONSE: "M\xE1y ch\u1EE7 tr\u1EA3 v\u1EC1 d\u1EEF li\u1EC7u kh\xF4ng h\u1EE3p l\u1EC7. H\xE3y th\u1EED l\u1EA1i ho\u1EB7c li\xEAn h\u1EC7 h\u1ED7 tr\u1EE3.",
209
- TIMEOUT: "Y\xEAu c\u1EA7u qu\xE1 th\u1EDDi gian. Ki\u1EC3m tra k\u1EBFt n\u1ED1i r\u1ED3i th\u1EED l\u1EA1i.",
210
- UNKNOWN: "Th\u1EED l\u1EA1i. N\u1EBFu l\u1ED7i ti\u1EBFp di\u1EC5n, h\xE3y li\xEAn h\u1EC7 h\u1ED7 tr\u1EE3."
211
- },
212
- en: {
213
- PERMISSION_DENIED: "Open Settings and allow Camera and Microphone, then try again.",
214
- NETWORK_UNAVAILABLE: "Check Wi-Fi or mobile data, then try again.",
215
- TOKEN_EXPIRED: "Your authentication has expired. Connect to the network and try again.",
216
- SDK_UPGRADE_REQUIRED: "Update the app to the latest version and install a new Development Build.",
217
- UNSUPPORTED_RUNTIME: "Install an iOS or Android Development Build; Expo Go and web are unsupported.",
218
- SESSION_CONFLICT: "End the active livestream before starting another one.",
219
- RECORDING_FAILED: "The server recording could not be created. Try again or contact support.",
220
- CONSENT_REQUIRED: "Accept recording consent before going live.",
221
- SDK_DISABLED: "Livestreaming is disabled for this app. Contact an administrator.",
222
- CONFIGURATION_ERROR: "Check the Provider and config plugin setup, then rebuild the app.",
223
- INVALID_RESPONSE: "The server returned invalid data. Try again or contact support.",
224
- TIMEOUT: "The request timed out. Check your connection and try again.",
225
- UNKNOWN: "Try again. If the problem continues, contact support."
226
- }
227
- };
228
- function LivestreamScreen(props) {
229
- const state = useLivestream();
230
- const strings = copy[state.locale];
231
- const insets = useSafeAreaInsets();
232
- const { width: windowWidth, height: windowHeight } = useWindowDimensions();
233
- const landscapeUi = windowWidth > windowHeight;
234
- const panelWidth = Math.min(360, Math.round(windowWidth * 0.42));
235
- const selectedOrientation = state.captureOrientation ?? "portrait";
236
- const orientationMismatch = props.physicalOrientation != null && props.physicalOrientation !== selectedOrientation;
237
- const rotateWarning = selectedOrientation === "landscape" ? strings.rotateLandscape : strings.rotatePortrait;
238
- const consentStorageKey = `@pickleball/consent:${props.input.consentVersion}`;
239
- const storedConsent = (() => {
240
- try {
241
- return globalThis.localStorage?.getItem(consentStorageKey) === "yes";
242
- } catch {
243
- return false;
244
- }
245
- })();
246
- const [consented, setConsented] = useState(storedConsent);
247
- const [confirmingEnd, setConfirmingEnd] = useState(false);
248
- const [visibility, setVisibility] = useState(
249
- props.input.visibility ?? "public"
250
- );
251
- const inLive = state.state === "standby" || state.state === "live" || state.state === "reconnecting";
252
- const isPublishing = state.state === "live";
253
- const [liveControlsVisible, setLiveControlsVisible] = useState(true);
254
- const [controlsTick, setControlsTick] = useState(0);
255
- const showLiveControls = () => {
256
- setLiveControlsVisible(true);
257
- setControlsTick((tick) => tick + 1);
258
- };
259
- useEffect(() => {
260
- if (inLive) setLiveControlsVisible(true);
261
- }, [inLive]);
262
- useEffect(() => {
263
- if (!inLive || !liveControlsVisible || confirmingEnd) return;
264
- const timer = setTimeout(() => setLiveControlsVisible(false), 1e4);
265
- return () => clearTimeout(timer);
266
- }, [inLive, liveControlsVisible, confirmingEnd, controlsTick]);
267
- useEffect(() => {
268
- if (state.state === "idle") void state.prepare().catch(() => void 0);
269
- }, [state.prepare, state.state]);
270
- const connect = (autoPublish = false) => {
271
- try {
272
- globalThis.localStorage?.setItem(consentStorageKey, "yes");
273
- } catch {
274
- }
275
- void state.start({ ...props.input, visibility }, { autoPublish }).catch(() => void 0);
276
- };
277
- const autoConnectedRef = React.useRef(false);
278
- useEffect(() => {
279
- if (props.autoConnect !== true || autoConnectedRef.current) return;
280
- if (state.state !== "preview" || !storedConsent) return;
281
- autoConnectedRef.current = true;
282
- connect();
283
- }, [props.autoConnect, state.state, storedConsent]);
284
- const theme = state.theme;
285
- const bodyFont = fontOf(theme);
286
- const boldFont = fontOf(theme, true);
287
- const displayFont = theme.displayFontFamily ? { fontFamily: theme.displayFontFamily } : boldFont;
288
- const button = (color) => ({
289
- minHeight: 40,
290
- minWidth: 44,
291
- paddingHorizontal: 16,
292
- alignItems: "center",
293
- justifyContent: "center",
294
- backgroundColor: color,
295
- borderRadius: 999
296
- });
297
- const secondaryButton = {
298
- ...button(theme.elevated),
299
- borderWidth: 1,
300
- borderColor: theme.hairline
301
- };
302
- const cta = (enabled) => ({
303
- minHeight: 48,
304
- alignItems: "center",
305
- justifyContent: "center",
306
- borderRadius: 999,
307
- backgroundColor: enabled ? theme.accent : theme.elevated,
308
- ...enabled ? {
309
- shadowColor: theme.accent,
310
- shadowOpacity: 0.45,
311
- shadowRadius: 14,
312
- shadowOffset: { width: 0, height: 0 },
313
- elevation: 8
314
- } : { borderWidth: 1, borderColor: theme.hairline }
315
- });
316
- const fab = (bg) => ({
317
- width: 48,
318
- height: 48,
319
- borderRadius: 24,
320
- alignItems: "center",
321
- justifyContent: "center",
322
- backgroundColor: bg,
323
- borderWidth: 1,
324
- borderColor: theme.hairline
325
- });
326
- const statusLabel = statusText(state.state, strings);
327
- return /* @__PURE__ */ jsxs(
328
- View,
329
- {
330
- style: {
331
- flex: 1,
332
- flexDirection: landscapeUi ? "row" : "column",
333
- backgroundColor: theme.background,
334
- paddingTop: insets.top,
335
- paddingBottom: insets.bottom,
336
- paddingLeft: insets.left,
337
- paddingRight: insets.right,
338
- ...props.style
339
- },
340
- children: [
341
- /* @__PURE__ */ jsx(View, { style: { flex: 1 }, children: props.renderPreview ? props.renderPreview(state) : inLive ? (
342
- // Live: camera full màn hình; chạm để hiện nút điều khiển.
343
- /* @__PURE__ */ jsx(Pressable, { style: { flex: 1 }, onPress: showLiveControls, children: /* @__PURE__ */ jsx(DefaultPreview, { state }) })
344
- ) : /* @__PURE__ */ jsx(
345
- AspectBox,
346
- {
347
- ratio: selectedOrientation === "landscape" ? 16 / 9 : 9 / 16,
348
- background: theme.background,
349
- children: /* @__PURE__ */ jsx(DefaultPreview, { state })
350
- }
351
- ) }),
352
- /* @__PURE__ */ jsx(
353
- View,
354
- {
355
- style: {
356
- position: "absolute",
357
- top: insets.top + 12,
358
- left: 12,
359
- right: 12,
360
- gap: 8
361
- },
362
- children: props.renderHeader ? props.renderHeader(state) : /* @__PURE__ */ jsxs(
363
- View,
364
- {
365
- style: {
366
- alignSelf: "flex-start",
367
- flexDirection: "row",
368
- alignItems: "center",
369
- gap: 8,
370
- paddingVertical: 6,
371
- paddingHorizontal: 12,
372
- borderRadius: 999,
373
- borderWidth: 1,
374
- borderColor: theme.hairline,
375
- backgroundColor: theme.overlay,
376
- ...state.state === "live" ? { shadowColor: theme.accent, shadowOpacity: 0.4, shadowRadius: 10, shadowOffset: { width: 0, height: 0 }, elevation: 6 } : {}
377
- },
378
- children: [
379
- state.state === "live" ? /* @__PURE__ */ jsx(View, { style: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.accent } }) : null,
380
- /* @__PURE__ */ jsx(
381
- Text,
382
- {
383
- selectable: true,
384
- style: [
385
- {
386
- color: state.state === "live" ? theme.accent : theme.foreground,
387
- fontSize: 12,
388
- fontWeight: "700",
389
- letterSpacing: 0.6
390
- },
391
- displayFont
392
- ],
393
- children: statusLabel
394
- }
395
- ),
396
- state.state === "live" ? /* @__PURE__ */ jsx(Text, { selectable: true, style: [{ color: theme.textSecondary, fontSize: 12, fontVariant: ["tabular-nums"] }, bodyFont], children: formatElapsed(state.elapsedMs) }) : null,
397
- state.sessionId ? /* @__PURE__ */ jsx(
398
- Text,
399
- {
400
- selectable: true,
401
- style: [{ color: theme.textSecondary, fontSize: 12, letterSpacing: 1 }, bodyFont],
402
- children: sessionShortCode(state.sessionId)
403
- }
404
- ) : null,
405
- inLive && state.deviceMetrics ? /* @__PURE__ */ jsx(
406
- Text,
407
- {
408
- selectable: true,
409
- numberOfLines: 1,
410
- style: [{ color: theme.textSecondary, fontSize: 12 }, bodyFont],
411
- children: [
412
- state.deviceMetrics.batteryPct !== void 0 ? `\u{1F50B}${state.deviceMetrics.batteryPct}%${state.deviceMetrics.charging ? "\u26A1" : ""}` : null,
413
- state.deviceMetrics.netType ? `\u{1F4F6}${state.deviceMetrics.netType}` : null,
414
- state.deviceMetrics.connQuality ?? null
415
- ].filter(Boolean).join(" ")
416
- }
417
- ) : null,
418
- !state.isOnline ? /* @__PURE__ */ jsx(Text, { selectable: true, style: [{ color: theme.danger, fontSize: 12 }, bodyFont], children: strings.offline }) : null
419
- ]
420
- }
421
- )
422
- }
423
- ),
424
- !inLive || state.error ? /* @__PURE__ */ jsx(View, { style: landscapeUi ? { width: panelWidth } : null, children: /* @__PURE__ */ jsx(
425
- ScrollView,
426
- {
427
- style: landscapeUi ? { flex: 1 } : void 0,
428
- scrollEnabled: landscapeUi,
429
- contentContainerStyle: landscapeUi ? { flexGrow: 1, justifyContent: "center" } : void 0,
430
- children: state.error ? props.renderError ? props.renderError(state) : /* @__PURE__ */ jsxs(View, { style: { padding: 14, gap: 10, backgroundColor: theme.overlay }, children: [
431
- /* @__PURE__ */ jsx(Text, { selectable: true, accessibilityRole: "alert", style: [{ color: theme.foreground, fontSize: 15, fontWeight: "700" }, displayFont], children: errorTitle[state.locale][state.error.code] }),
432
- /* @__PURE__ */ jsx(Text, { selectable: true, style: [{ color: theme.textSecondary, fontSize: 13 }, bodyFont], children: remediation[state.locale][state.error.code] }),
433
- /* @__PURE__ */ jsxs(Text, { selectable: true, style: [{ color: theme.muted, fontSize: 11 }, bodyFont], children: [
434
- strings.errorDetail,
435
- ": ",
436
- state.error.message
437
- ] }),
438
- /* @__PURE__ */ jsx(Pressable, { accessibilityRole: "button", onPress: () => void state.retry(), style: cta(true), children: /* @__PURE__ */ jsx(Text, { style: [{ color: theme.ink, fontSize: 14, fontWeight: "800" }, boldFont], children: strings.retry }) })
439
- ] }) : props.renderControls ? props.renderControls(state) : /* @__PURE__ */ jsxs(View, { style: { padding: 14, gap: 10, backgroundColor: theme.overlay }, children: [
440
- state.state === "preview" ? /* @__PURE__ */ jsxs(Fragment, { children: [
441
- /* @__PURE__ */ jsx(ControlRow, { label: strings.orientationLabel, theme, children: /* @__PURE__ */ jsx(
442
- Segmented,
443
- {
444
- theme,
445
- value: selectedOrientation,
446
- onSelect: (value) => void state.setCaptureOrientation(value),
447
- options: [
448
- { value: "portrait", label: strings.portrait },
449
- { value: "landscape", label: strings.landscape }
450
- ]
451
- }
452
- ) }),
453
- /* @__PURE__ */ jsx(ControlRow, { label: strings.visibilityLabel, theme, children: /* @__PURE__ */ jsx(
454
- Segmented,
455
- {
456
- theme,
457
- value: visibility,
458
- onSelect: (value) => setVisibility(value),
459
- options: [
460
- { value: "public", label: strings.visPublic },
461
- { value: "private", label: strings.visPrivate }
462
- ]
463
- }
464
- ) }),
465
- /* @__PURE__ */ jsx(ControlRow, { label: strings.cameraLabel, theme, children: /* @__PURE__ */ jsx(
466
- Segmented,
467
- {
468
- theme,
469
- value: state.isCameraFront ? "front" : "back",
470
- onSelect: (value) => {
471
- if (value === "front" !== state.isCameraFront) {
472
- void state.switchCamera();
473
- }
474
- },
475
- options: [
476
- { value: "back", label: strings.cameraBack },
477
- { value: "front", label: strings.cameraFront }
478
- ]
479
- }
480
- ) }),
481
- (() => {
482
- const backLenses = state.cameras.filter(
483
- (camera) => camera.facing === "environment" && !/dual|triple/i.test(camera.label)
484
- );
485
- if (backLenses.length < 2 || state.isCameraFront) return null;
486
- const lensName = (label) => /ultra\s*wide/i.test(label) ? "0.5x" : /telephoto/i.test(label) ? "Tele" : "1x";
487
- return /* @__PURE__ */ jsx(ControlRow, { label: strings.lensLabel, theme, children: /* @__PURE__ */ jsx(
488
- Segmented,
489
- {
490
- theme,
491
- value: state.selectedCameraId,
492
- onSelect: (deviceId) => void state.selectCamera(deviceId),
493
- options: backLenses.map((camera) => ({
494
- value: camera.deviceId,
495
- label: lensName(camera.label),
496
- accessibilityLabel: camera.label
497
- }))
498
- }
499
- ) });
500
- })(),
501
- /* @__PURE__ */ jsx(Text, { selectable: true, style: [{ color: theme.muted, fontSize: 11 }, bodyFont], children: selectedOrientation === "landscape" ? strings.landscapeHint : strings.portraitHint }),
502
- /* @__PURE__ */ jsxs(
503
- Pressable,
504
- {
505
- accessibilityRole: "checkbox",
506
- accessibilityLabel: strings.consent,
507
- accessibilityState: { checked: consented },
508
- onPress: () => setConsented((value) => !value),
509
- style: { minHeight: 40, flexDirection: "row", alignItems: "center", gap: 10 },
510
- children: [
511
- /* @__PURE__ */ jsx(
512
- View,
513
- {
514
- style: {
515
- width: 22,
516
- height: 22,
517
- borderRadius: theme.radiusSm,
518
- borderWidth: 1.5,
519
- borderColor: consented ? theme.accent : theme.hairline,
520
- backgroundColor: consented ? theme.accent : theme.surface,
521
- alignItems: "center",
522
- justifyContent: "center"
523
- },
524
- children: consented ? /* @__PURE__ */ jsx(Text, { style: { color: theme.ink, fontSize: 13, fontWeight: "800" }, children: "\u2713" }) : null
525
- }
526
- ),
527
- /* @__PURE__ */ jsx(Text, { selectable: true, style: [{ color: theme.textSecondary, fontSize: 13, flex: 1 }, bodyFont], children: strings.consentDetail })
528
- ]
529
- }
530
- ),
531
- /* @__PURE__ */ jsxs(View, { style: { flexDirection: "row", gap: 8 }, children: [
532
- /* @__PURE__ */ jsx(
533
- Pressable,
534
- {
535
- accessibilityRole: "button",
536
- accessibilityLabel: strings.startStandby,
537
- accessibilityState: { disabled: !consented },
538
- disabled: !consented,
539
- onPress: () => connect(false),
540
- style: [
541
- secondaryButton,
542
- {
543
- flex: 1,
544
- minHeight: 48,
545
- backgroundColor: consented ? theme.elevated : theme.surface
546
- }
547
- ],
548
- children: /* @__PURE__ */ jsx(
549
- Text,
550
- {
551
- style: [
552
- {
553
- color: consented ? theme.foreground : theme.muted,
554
- fontSize: 14,
555
- fontWeight: "800"
556
- },
557
- boldFont
558
- ],
559
- children: `\u{1F7E1} ${strings.startStandby}`
560
- }
561
- )
562
- }
563
- ),
564
- /* @__PURE__ */ jsx(
565
- Pressable,
566
- {
567
- accessibilityRole: "button",
568
- accessibilityLabel: strings.startLive,
569
- accessibilityState: { disabled: !consented },
570
- disabled: !consented,
571
- onPress: () => connect(true),
572
- style: [cta(consented), { flex: 1 }],
573
- children: /* @__PURE__ */ jsx(
574
- Text,
575
- {
576
- style: [
577
- {
578
- color: consented ? theme.ink : theme.muted,
579
- fontSize: 14,
580
- fontWeight: "800"
581
- },
582
- boldFont
583
- ],
584
- children: `\u{1F7E2} ${strings.startLive}`
585
- }
586
- )
587
- }
588
- )
589
- ] })
590
- ] }) : null,
591
- state.state === "ended" ? /* @__PURE__ */ jsxs(View, { style: { gap: 10 }, children: [
592
- /* @__PURE__ */ jsx(Text, { selectable: true, style: [{ color: theme.foreground, textAlign: "center", fontSize: 15, fontWeight: "700" }, displayFont], children: strings.ended }),
593
- /* @__PURE__ */ jsx(
594
- Pressable,
595
- {
596
- accessibilityRole: "button",
597
- onPress: () => {
598
- setConfirmingEnd(false);
599
- props.onEnded?.();
600
- void state.prepare();
601
- },
602
- style: cta(true),
603
- children: /* @__PURE__ */ jsx(Text, { style: [{ color: theme.ink, fontSize: 14, fontWeight: "800" }, boldFont], children: strings.backToPreview })
604
- }
605
- )
606
- ] }) : null
607
- ] })
608
- }
609
- ) }) : null,
610
- inLive && liveControlsVisible && !confirmingEnd ? /* @__PURE__ */ jsxs(
611
- View,
612
- {
613
- style: {
614
- position: "absolute",
615
- right: 16,
616
- bottom: insets.bottom + 24,
617
- gap: 12,
618
- alignItems: "center"
619
- },
620
- children: [
621
- state.state === "standby" && orientationMismatch ? /* @__PURE__ */ jsx(
622
- Text,
623
- {
624
- style: [
625
- {
626
- maxWidth: 200,
627
- textAlign: "center",
628
- color: theme.danger,
629
- fontSize: 11,
630
- fontWeight: "700"
631
- },
632
- boldFont
633
- ],
634
- children: rotateWarning
635
- }
636
- ) : null,
637
- /* @__PURE__ */ jsx(
638
- Pressable,
639
- {
640
- accessibilityRole: "button",
641
- accessibilityLabel: isPublishing ? strings.pauseLabel : strings.playLabel,
642
- accessibilityState: {
643
- disabled: state.state === "standby" && orientationMismatch
644
- },
645
- disabled: state.state === "standby" && orientationMismatch,
646
- onPress: () => {
647
- showLiveControls();
648
- if (isPublishing) void state.stopPublishing();
649
- else void state.startPublishing();
650
- },
651
- style: [
652
- fab(
653
- state.state === "standby" && orientationMismatch ? theme.elevated : theme.accent
654
- ),
655
- { borderColor: theme.accent }
656
- ],
657
- children: /* @__PURE__ */ jsx(Text, { style: { fontSize: 18, color: theme.ink, fontWeight: "800" }, children: isPublishing ? "\u23F8" : "\u25B6" })
658
- }
659
- ),
660
- /* @__PURE__ */ jsx(
661
- Pressable,
662
- {
663
- accessibilityRole: "button",
664
- accessibilityLabel: strings.flip,
665
- onPress: () => {
666
- showLiveControls();
667
- void state.switchCamera();
668
- },
669
- style: fab(theme.overlay),
670
- children: /* @__PURE__ */ jsx(Text, { style: { fontSize: 20 }, children: "\u{1F504}" })
671
- }
672
- ),
673
- /* @__PURE__ */ jsx(
674
- Pressable,
675
- {
676
- accessibilityRole: "switch",
677
- accessibilityLabel: strings.mic,
678
- accessibilityState: { checked: state.isMicrophoneEnabled },
679
- onPress: () => {
680
- showLiveControls();
681
- void state.setMicrophoneEnabled(!state.isMicrophoneEnabled);
682
- },
683
- style: [
684
- fab(theme.overlay),
685
- state.isMicrophoneEnabled ? null : { borderColor: theme.danger }
686
- ],
687
- children: /* @__PURE__ */ jsx(Text, { style: { fontSize: 20 }, children: state.isMicrophoneEnabled ? "\u{1F399}\uFE0F" : "\u{1F507}" })
688
- }
689
- ),
690
- /* @__PURE__ */ jsx(
691
- Pressable,
692
- {
693
- accessibilityRole: "button",
694
- accessibilityLabel: strings.endLabel,
695
- onPress: () => setConfirmingEnd(true),
696
- style: [fab(theme.danger), { borderColor: theme.danger }],
697
- children: /* @__PURE__ */ jsx(Text, { style: { fontSize: 18, color: theme.ink, fontWeight: "800" }, children: "\u25A0" })
698
- }
699
- )
700
- ]
701
- }
702
- ) : null,
703
- confirmingEnd ? /* @__PURE__ */ jsx(
704
- View,
705
- {
706
- style: {
707
- position: "absolute",
708
- inset: 0,
709
- alignItems: "center",
710
- justifyContent: "center",
711
- backgroundColor: theme.overlay
712
- },
713
- children: /* @__PURE__ */ jsxs(
714
- View,
715
- {
716
- style: {
717
- width: 280,
718
- gap: 10,
719
- padding: 16,
720
- borderRadius: theme.radius,
721
- borderWidth: 1,
722
- borderColor: theme.hairline,
723
- backgroundColor: theme.surface
724
- },
725
- children: [
726
- /* @__PURE__ */ jsx(Text, { selectable: true, style: [{ color: theme.foreground, fontSize: 15, fontWeight: "700" }, displayFont], children: strings.endQuestion }),
727
- /* @__PURE__ */ jsx(
728
- Pressable,
729
- {
730
- accessibilityRole: "button",
731
- onPress: () => {
732
- setConfirmingEnd(false);
733
- void state.stop("user");
734
- },
735
- style: button(theme.danger),
736
- children: /* @__PURE__ */ jsx(Text, { style: [{ color: theme.ink, fontSize: 14, fontWeight: "800" }, boldFont], children: strings.end })
737
- }
738
- ),
739
- /* @__PURE__ */ jsx(Pressable, { accessibilityRole: "button", onPress: () => setConfirmingEnd(false), style: secondaryButton, children: /* @__PURE__ */ jsx(Text, { style: [{ color: theme.textSecondary, fontSize: 13, fontWeight: "600" }, bodyFont], children: strings.cancel }) })
740
- ]
741
- }
742
- )
743
- }
744
- ) : null
745
- ]
746
- }
747
- );
748
- }
749
- var fontOf = (theme, bold = false) => bold ? { fontFamily: theme.fontFamilyBold ?? theme.fontFamily } : { fontFamily: theme.fontFamily };
750
- function Segmented({
751
- options,
752
- value,
753
- onSelect,
754
- theme
755
- }) {
756
- return /* @__PURE__ */ jsx(
757
- View,
758
- {
759
- style: {
760
- flex: 1,
761
- flexDirection: "row",
762
- padding: 3,
763
- gap: 3,
764
- borderRadius: 999,
765
- borderWidth: 1,
766
- borderColor: theme.hairline,
767
- backgroundColor: theme.surface
768
- },
769
- children: options.map((option) => {
770
- const selected = option.value === value;
771
- return /* @__PURE__ */ jsx(
772
- Pressable,
773
- {
774
- accessibilityRole: "button",
775
- accessibilityLabel: option.accessibilityLabel ?? option.label,
776
- accessibilityState: { selected },
777
- onPress: () => onSelect(option.value),
778
- style: {
779
- flex: 1,
780
- minHeight: 32,
781
- alignItems: "center",
782
- justifyContent: "center",
783
- borderRadius: 999,
784
- backgroundColor: selected ? theme.accent : "transparent"
785
- },
786
- children: /* @__PURE__ */ jsx(
787
- Text,
788
- {
789
- numberOfLines: 1,
790
- style: [
791
- {
792
- fontSize: 12,
793
- fontWeight: "700",
794
- color: selected ? theme.ink : theme.textSecondary
795
- },
796
- fontOf(theme, true)
797
- ],
798
- children: option.label
799
- }
800
- )
801
- },
802
- option.value
803
- );
804
- })
805
- }
806
- );
807
- }
808
- function ControlRow({
809
- label,
810
- theme,
811
- children
812
- }) {
813
- return /* @__PURE__ */ jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: 10 }, children: [
814
- /* @__PURE__ */ jsx(
815
- Text,
816
- {
817
- numberOfLines: 1,
818
- style: [
819
- {
820
- width: 78,
821
- fontSize: 10,
822
- letterSpacing: 1,
823
- textTransform: "uppercase",
824
- fontWeight: "700",
825
- color: theme.muted
826
- },
827
- fontOf(theme, true)
828
- ],
829
- children: label
830
- }
831
- ),
832
- children
833
- ] });
834
- }
835
- function AspectBox({
836
- ratio,
837
- background,
838
- children
839
- }) {
840
- const [size, setSize] = useState(null);
841
- return /* @__PURE__ */ jsx(
842
- View,
843
- {
844
- style: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: background },
845
- onLayout: (event) => {
846
- const { width, height } = event.nativeEvent.layout;
847
- setSize({ w: width, h: height });
848
- },
849
- children: size ? /* @__PURE__ */ jsx(
850
- View,
851
- {
852
- style: {
853
- width: Math.min(size.w, size.h * ratio),
854
- height: Math.min(size.h, size.w / ratio),
855
- overflow: "hidden"
856
- },
857
- children
858
- }
859
- ) : null
860
- }
861
- );
862
- }
863
- function DefaultPreview({ state }) {
864
- const preview = state.previewTrack;
865
- if (!preview) return /* @__PURE__ */ jsx(View, { style: { flex: 1, backgroundColor: state.theme.background } });
866
- if (preview.kind === "rtmp") {
867
- const rtmp = __require("@pickleball/rtmp-native");
868
- return /* @__PURE__ */ jsx(rtmp.PickleballRtmpPreviewView, { style: { flex: 1 } });
869
- }
870
- const liveKit = __require("@livekit/react-native");
871
- const liveKitPreview = preview;
872
- const remountKey = `${state.selectedCameraId ?? "auto"}:${state.isCameraFront ? "front" : "back"}`;
873
- return liveKitPreview.trackRef ? /* @__PURE__ */ jsx(liveKit.VideoTrack, { trackRef: liveKitPreview.trackRef, mirror: state.isCameraFront, objectFit: "cover", style: { flex: 1 } }, remountKey) : /* @__PURE__ */ jsx(liveKit.VideoView, { videoTrack: liveKitPreview.videoTrack, mirror: state.isCameraFront, objectFit: "cover", style: { flex: 1 } }, remountKey);
874
- }
875
- function formatElapsed(milliseconds) {
876
- const total = Math.floor(milliseconds / 1e3);
877
- const hours = Math.floor(total / 3600);
878
- const minutes = Math.floor(total % 3600 / 60);
879
- const seconds = total % 60;
880
- return [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
881
- }
882
- function statusText(state, strings) {
883
- return strings.states[state];
884
- }
885
-
886
84
  // src/http-session-provider.ts
887
85
  var defaultPaths = {
888
86
  bootstrap: "/bootstrap",
@@ -894,44 +92,11 @@ var defaultPaths = {
894
92
  };
895
93
  function createHttpSessionProvider(options) {
896
94
  const baseUrl = normalizeBaseUrl(options.baseUrl);
897
- const fetchImplementation = options.fetch ?? globalThis.fetch;
898
- if (typeof fetchImplementation !== "function") {
899
- throw new ExpoSdkConfigurationError("A Fetch API implementation is required");
900
- }
95
+ const postJson = createJsonPoster({ fetch: options.fetch, timeoutMs: options.timeoutMs });
901
96
  const paths = { ...defaultPaths, ...options.paths };
902
97
  async function post(path, body) {
903
- const controller = new AbortController();
904
- const timeout = setTimeout(
905
- () => controller.abort(),
906
- options.timeoutMs ?? 1e4
907
- );
908
- try {
909
- const extraHeaders = typeof options.headers === "function" ? await options.headers() : options.headers ?? {};
910
- const response = await fetchImplementation(joinUrl(baseUrl, path), {
911
- method: "POST",
912
- headers: { "Content-Type": "application/json", ...extraHeaders },
913
- body: JSON.stringify(body),
914
- signal: controller.signal,
915
- redirect: "error"
916
- });
917
- const envelope = await response.json().catch(() => {
918
- throw new InvalidResponseError();
919
- });
920
- if (!response.ok) throw parseProxyError(envelope, response.status);
921
- if (!isRecord(envelope) || !("data" in envelope)) {
922
- throw new InvalidResponseError();
923
- }
924
- return envelope.data;
925
- } catch (error) {
926
- if (controller.signal.aborted) throw new ExpoSdkTimeoutError();
927
- if (error instanceof PickleballExpoError) throw error;
928
- if (error instanceof TypeError) {
929
- throw new NetworkUnavailableError(void 0, { cause: error });
930
- }
931
- throw error;
932
- } finally {
933
- clearTimeout(timeout);
934
- }
98
+ const extraHeaders = typeof options.headers === "function" ? await options.headers() : options.headers ?? {};
99
+ return postJson(joinUrl(baseUrl, path), body, extraHeaders);
935
100
  }
936
101
  return {
937
102
  async bootstrap(input) {
@@ -963,76 +128,362 @@ function createHttpSessionProvider(options) {
963
128
  }
964
129
  };
965
130
  }
966
- var PRIVATE_LAN_HOST = /^(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})$/;
967
- function normalizeBaseUrl(value) {
131
+
132
+ // src/camera-grant-provider.ts
133
+ var CAMERA_GRANT_STORAGE_KEY = "@pickleball/expo-sdk/camera-grant";
134
+ var DEFAULT_LOCAL_CONFIG = {
135
+ enabled: true,
136
+ protocolVersion: 1,
137
+ minSdkVersion: "0.0.0",
138
+ recommendedSdkVersion: PICKLEBALL_EXPO_SDK_VERSION,
139
+ videoQuality: 720,
140
+ reconnectTimeoutMs: 3e4,
141
+ backgroundGraceMs: 9e4,
142
+ telemetryIntervalMs: 6e4,
143
+ maxSessionDurationMs: 6 * 60 * 6e4
144
+ };
145
+ function parseHandle(raw) {
146
+ if (!raw) return null;
968
147
  try {
969
- const url = new URL(value);
970
- const loopbackHosts = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
971
- const secure = url.protocol === "https:";
972
- const localDevelopment = url.protocol === "http:" && loopbackHosts.has(url.hostname);
973
- const devPrivateLan = typeof __DEV__ !== "undefined" && __DEV__ === true && url.protocol === "http:" && PRIVATE_LAN_HOST.test(url.hostname);
974
- if (!secure && !localDevelopment && !devPrivateLan || url.username || url.password) {
975
- throw new Error();
148
+ const value = JSON.parse(raw);
149
+ if (!isRecord(value) || typeof value.sessionId !== "string" || typeof value.base !== "string" || typeof value.token !== "string" || typeof value.tokenExpiresAt !== "number") {
150
+ return null;
976
151
  }
977
- return value.replace(/\/+$/, "");
152
+ return {
153
+ sessionId: value.sessionId,
154
+ base: value.base,
155
+ token: value.token,
156
+ tokenExpiresAt: value.tokenExpiresAt,
157
+ ...value.quality === 720 || value.quality === 1080 ? { quality: value.quality } : {},
158
+ savedAt: typeof value.savedAt === "number" ? value.savedAt : 0
159
+ };
978
160
  } catch {
979
- throw new ExpoSdkConfigurationError(
980
- "baseUrl must use HTTPS (HTTP is allowed only for localhost loopback)"
981
- );
161
+ return null;
982
162
  }
983
163
  }
984
- function joinUrl(base, path) {
985
- return `${base}/${path.replace(/^\/+/, "")}`;
986
- }
987
- function parseProxyError(envelope, httpStatus) {
988
- const error = isRecord(envelope) && isRecord(envelope.error) && typeof envelope.error.code === "string" ? errorFromCode(
989
- envelope.error.code,
990
- typeof envelope.error.message === "string" ? envelope.error.message : void 0
991
- ) : new InvalidResponseError();
992
- return Object.assign(error, { httpStatus });
993
- }
994
- function isRecord(value) {
995
- return typeof value === "object" && value !== null && !Array.isArray(value);
164
+ function withStatus(error) {
165
+ if (httpStatusOf(error) === 401) {
166
+ return Object.assign(new TokenExpiredError(), { httpStatus: 401 });
167
+ }
168
+ return error;
996
169
  }
997
- function isFiniteNumber(value) {
998
- return typeof value === "number" && Number.isFinite(value);
170
+ function createCameraGrantProvider(options) {
171
+ const storage = options.storage ?? createDefaultStorage();
172
+ const storageKey = options.storageKey ?? CAMERA_GRANT_STORAGE_KEY;
173
+ const post = createJsonPoster({ fetch: options.fetch, timeoutMs: options.timeoutMs });
174
+ const listeners = /* @__PURE__ */ new Set();
175
+ let handle = null;
176
+ let handleLoaded = null;
177
+ let activeGrant = null;
178
+ let pendingResume = null;
179
+ const emit = () => {
180
+ for (const listener of listeners) listener();
181
+ };
182
+ async function loadHandle() {
183
+ if (!handleLoaded) {
184
+ handleLoaded = storage.getItem(storageKey).then((raw) => {
185
+ if (handle === null) handle = parseHandle(raw);
186
+ }).catch(() => void 0);
187
+ }
188
+ await handleLoaded;
189
+ }
190
+ async function saveHandle(next) {
191
+ handle = next;
192
+ try {
193
+ if (next) await storage.setItem(storageKey, JSON.stringify(next));
194
+ else await storage.removeItem(storageKey);
195
+ } catch {
196
+ }
197
+ }
198
+ function setActive(grant) {
199
+ activeGrant = grant;
200
+ emit();
201
+ }
202
+ async function adopt(grant, quality) {
203
+ const base = sessionApiBaseFromTelemetryEndpoint(grant.telemetry.endpoint);
204
+ await saveHandle({
205
+ sessionId: grant.sessionId,
206
+ base,
207
+ token: grant.telemetry.token,
208
+ tokenExpiresAt: grant.telemetry.expiresAt,
209
+ ...quality !== void 0 ? { quality } : handle?.quality !== void 0 ? { quality: handle.quality } : {},
210
+ savedAt: Date.now()
211
+ });
212
+ setActive(grant);
213
+ }
214
+ function requireHandle(sessionId) {
215
+ if (!handle || handle.sessionId !== sessionId) {
216
+ throw Object.assign(
217
+ new SessionConflictError("No camera grant is held for this session"),
218
+ { httpStatus: 404 }
219
+ );
220
+ }
221
+ return handle;
222
+ }
223
+ function bearer(current) {
224
+ return { Authorization: `Bearer ${current.token}` };
225
+ }
226
+ async function call(current, action, body) {
227
+ try {
228
+ return await post(`${current.base}/${action}`, body, bearer(current));
229
+ } catch (error) {
230
+ throw withStatus(error);
231
+ }
232
+ }
233
+ const provider = {
234
+ async bootstrap() {
235
+ await loadHandle();
236
+ return {
237
+ config: { ...DEFAULT_LOCAL_CONFIG, ...options.localConfig },
238
+ serverTime: Date.now(),
239
+ rolloutEnabled: true
240
+ };
241
+ },
242
+ async start(input) {
243
+ await loadHandle();
244
+ const resumeGrant = pendingResume;
245
+ if (resumeGrant && resumeGrantMatches(input.externalSessionId, resumeGrant.sessionId)) {
246
+ pendingResume = null;
247
+ return resumeGrant;
248
+ }
249
+ const raw = await options.requestGrant(input);
250
+ if (!isCameraGrant(raw)) {
251
+ throw new InvalidResponseError("The partner backend returned an invalid camera grant");
252
+ }
253
+ await adopt(raw, input.quality);
254
+ if (input.standby !== true && raw.status !== "live") {
255
+ const current = requireHandle(raw.sessionId);
256
+ try {
257
+ const data = await call(current, "publish", {
258
+ sessionId: raw.sessionId,
259
+ ...input.quality !== void 0 ? { quality: input.quality } : {}
260
+ });
261
+ if (!isSessionStatus(data)) throw new InvalidResponseError();
262
+ } catch (error) {
263
+ await call(current, "end", { sessionId: raw.sessionId, reason: "error" }).catch(
264
+ () => void 0
265
+ );
266
+ await saveHandle(null);
267
+ setActive(null);
268
+ throw error;
269
+ }
270
+ setActive({ ...raw, status: "live" });
271
+ }
272
+ options.onGrant?.(activeGrant ?? raw, "start");
273
+ return raw;
274
+ },
275
+ async refresh(sessionId) {
276
+ await loadHandle();
277
+ const current = requireHandle(sessionId);
278
+ const data = await call(current, "refresh", {});
279
+ if (!isCameraGrant(data)) throw new InvalidResponseError();
280
+ await adopt(data, current.quality);
281
+ options.onGrant?.(data, "refresh");
282
+ return {
283
+ participantToken: data.participantToken,
284
+ tokenExpiresAt: data.tokenExpiresAt,
285
+ telemetry: data.telemetry,
286
+ config: data.config
287
+ };
288
+ },
289
+ async end(input) {
290
+ await loadHandle();
291
+ if (!handle || handle.sessionId !== input.sessionId) {
292
+ return;
293
+ }
294
+ const current = handle;
295
+ if (current.tokenExpiresAt <= Date.now()) {
296
+ await saveHandle(null);
297
+ setActive(null);
298
+ return;
299
+ }
300
+ try {
301
+ await call(current, "end", { sessionId: input.sessionId, reason: input.reason });
302
+ } catch (error) {
303
+ const status = httpStatusOf(error);
304
+ if (status !== 404 && status !== 409 && status !== 410) throw error;
305
+ }
306
+ await saveHandle(null);
307
+ setActive(null);
308
+ },
309
+ async publish(sessionId) {
310
+ await loadHandle();
311
+ const current = requireHandle(sessionId);
312
+ const data = await call(current, "publish", {
313
+ sessionId,
314
+ ...current.quality !== void 0 ? { quality: current.quality } : {}
315
+ });
316
+ if (!isSessionStatus(data)) throw new InvalidResponseError();
317
+ if (activeGrant) setActive({ ...activeGrant, status: "live" });
318
+ },
319
+ async unpublish(sessionId) {
320
+ await loadHandle();
321
+ const current = requireHandle(sessionId);
322
+ const data = await call(current, "unpublish", { sessionId });
323
+ if (!isSessionStatus(data)) throw new InvalidResponseError();
324
+ if (activeGrant) setActive({ ...activeGrant, status: "scheduled" });
325
+ },
326
+ adoptResumeGrant(grant) {
327
+ pendingResume = grant;
328
+ },
329
+ async resume(sessionId) {
330
+ await loadHandle();
331
+ if (!handle || handle.sessionId !== sessionId) return { kind: "gone" };
332
+ const current = handle;
333
+ if (current.tokenExpiresAt <= Date.now()) {
334
+ await saveHandle(null);
335
+ return { kind: "gone" };
336
+ }
337
+ let data;
338
+ try {
339
+ data = await post(`${current.base}/refresh`, {}, bearer(current));
340
+ } catch (error) {
341
+ const status = httpStatusOf(error);
342
+ if (status === void 0) return { kind: "retry" };
343
+ const verdict = classifyResumeStatus(status);
344
+ return { kind: verdict === "ok" ? "retry" : verdict };
345
+ }
346
+ if (!isCameraGrant(data)) return { kind: "retry" };
347
+ await adopt(data, current.quality);
348
+ options.onGrant?.(data, "resume");
349
+ return { kind: "grant", grant: data, live: data.status === "live" };
350
+ },
351
+ getActiveGrant: () => activeGrant,
352
+ subscribe(listener) {
353
+ listeners.add(listener);
354
+ return () => {
355
+ listeners.delete(listener);
356
+ };
357
+ },
358
+ async hasResumableSession() {
359
+ await loadHandle();
360
+ return handle !== null;
361
+ },
362
+ async clearStoredSession() {
363
+ pendingResume = null;
364
+ await saveHandle(null);
365
+ setActive(null);
366
+ }
367
+ };
368
+ return provider;
999
369
  }
1000
- function isBootstrap(value) {
1001
- return isRecord(value) && isSdkRemoteConfig(value.config) && isFiniteNumber(value.serverTime) && typeof value.rolloutEnabled === "boolean" && (value.message === void 0 || typeof value.message === "string");
370
+ function useCameraGrant(provider) {
371
+ const React = __require("react");
372
+ return React.useSyncExternalStore(
373
+ provider.subscribe,
374
+ provider.getActiveGrant,
375
+ provider.getActiveGrant
376
+ );
1002
377
  }
1003
- function isTelemetry(value) {
1004
- return isRecord(value) && typeof value.endpoint === "string" && /^https:\/\//.test(value.endpoint) && typeof value.token === "string" && value.token.length > 0 && isFiniteNumber(value.expiresAt);
378
+
379
+ // src/resume.ts
380
+ function withResume(provider, resume) {
381
+ let pending = null;
382
+ return {
383
+ bootstrap: provider.bootstrap.bind(provider),
384
+ refresh: provider.refresh.bind(provider),
385
+ end: provider.end.bind(provider),
386
+ publish: provider.publish?.bind(provider),
387
+ unpublish: provider.unpublish?.bind(provider),
388
+ async start(input) {
389
+ const grant = pending;
390
+ if (grant && resumeGrantMatches(input.externalSessionId, grant.sessionId)) {
391
+ pending = null;
392
+ return grant;
393
+ }
394
+ return provider.start(input);
395
+ },
396
+ adoptResumeGrant(grant) {
397
+ pending = grant;
398
+ },
399
+ resume
400
+ };
1005
401
  }
1006
- function isSessionGrant(value) {
1007
- return isRecord(value) && typeof value.sessionId === "string" && value.sessionId.length > 0 && typeof value.serverUrl === "string" && // wss:// cho mode LiveKit, rtmp(s):// cho mode device-rtmp. Trước đây chỉ
1008
- // chấp nhận wss:// nên grant RTMP bị coi là "phản hồi máy chủ không hợp lệ".
1009
- // Vẫn chặn ws:// trần để không rơi xuống WebSocket không mã hoá.
1010
- /^(wss|rtmps?):\/\//.test(value.serverUrl) && typeof value.participantToken === "string" && value.participantToken.length > 0 && (value.playbackUrl === null || typeof value.playbackUrl === "string") && isFiniteNumber(value.tokenExpiresAt) && isTelemetry(value.telemetry) && isSdkRemoteConfig(value.config);
402
+ function grantFromRefreshPayload(data) {
403
+ if (!isRecord(data) || typeof data.sessionId !== "string" || typeof data.serverUrl !== "string" || typeof data.participantToken !== "string" || !isFiniteNumber(data.tokenExpiresAt) || !isTelemetryCredentials(data.telemetry) || !isSdkRemoteConfig(data.config)) {
404
+ return null;
405
+ }
406
+ return {
407
+ sessionId: data.sessionId,
408
+ serverUrl: data.serverUrl,
409
+ participantToken: data.participantToken,
410
+ playbackUrl: typeof data.playbackUrl === "string" ? data.playbackUrl : null,
411
+ tokenExpiresAt: data.tokenExpiresAt,
412
+ telemetry: data.telemetry,
413
+ config: data.config
414
+ };
1011
415
  }
1012
- function isSessionStatus(value) {
1013
- return isRecord(value) && typeof value.sessionId === "string" && value.sessionId.length > 0 && typeof value.status === "string" && value.status.length > 0;
416
+ function createHttpResumeFetcher(options) {
417
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
418
+ const timeoutMs = options.timeoutMs ?? 1e4;
419
+ return async (sessionId) => {
420
+ const controller = new AbortController();
421
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
422
+ try {
423
+ const headers = typeof options.headers === "function" ? await options.headers() : options.headers ?? {};
424
+ const response = await fetchImplementation(options.url(sessionId), {
425
+ method: "POST",
426
+ headers: { "Content-Type": "application/json", ...headers },
427
+ body: JSON.stringify({}),
428
+ signal: controller.signal
429
+ });
430
+ const verdict = classifyResumeStatus(response.status);
431
+ if (verdict !== "ok") return { kind: verdict };
432
+ const envelope = await response.json().catch(() => null);
433
+ const grant = grantFromRefreshPayload(envelope?.data);
434
+ if (!grant) return { kind: "retry" };
435
+ const status = (envelope?.data).status;
436
+ return { kind: "grant", grant, live: status === "live" };
437
+ } catch {
438
+ return { kind: "retry" };
439
+ } finally {
440
+ clearTimeout(timer);
441
+ }
442
+ };
1014
443
  }
1015
- function isSessionRefresh(value) {
1016
- if (!isRecord(value) || !isSdkRemoteConfig(value.config)) return false;
1017
- if (value.participantToken !== void 0 && (typeof value.participantToken !== "string" || !value.participantToken)) {
1018
- return false;
1019
- }
1020
- if (value.tokenExpiresAt !== void 0 && !isFiniteNumber(value.tokenExpiresAt)) {
1021
- return false;
1022
- }
1023
- return value.telemetry === void 0 || isTelemetry(value.telemetry);
444
+
445
+ // src/local-video-quality.ts
446
+ function withLocalVideoQuality(provider, resolution) {
447
+ return {
448
+ ...provider,
449
+ async bootstrap(input) {
450
+ const result = await provider.bootstrap(input);
451
+ return { ...result, config: { ...result.config, videoQuality: resolution() } };
452
+ },
453
+ async start(input) {
454
+ const quality = resolution();
455
+ const result = await provider.start({ ...input, quality });
456
+ return { ...result, config: { ...result.config, videoQuality: quality } };
457
+ },
458
+ async refresh(sessionId) {
459
+ const result = await provider.refresh(sessionId);
460
+ return { ...result, config: { ...result.config, videoQuality: resolution() } };
461
+ },
462
+ end: (input) => provider.end(input),
463
+ // publish/unpublish là optional trong contract — forward nguyên trạng.
464
+ publish: provider.publish ? (sessionId) => provider.publish(sessionId) : void 0,
465
+ unpublish: provider.unpublish ? (sessionId) => provider.unpublish(sessionId) : void 0
466
+ };
1024
467
  }
1025
468
  export {
469
+ AUDIO_BITRATE_BPS,
470
+ CAMERA_GRANT_STORAGE_KEY,
471
+ CAMERA_SETTINGS_STORAGE_KEY,
1026
472
  ConsentRequiredError,
473
+ DEFAULT_CAMERA_SETTINGS,
474
+ DEFAULT_LOCAL_CONFIG,
475
+ DEFAULT_UPLOAD_URL,
1027
476
  ExpoSdkConfigurationError,
1028
477
  ExpoSdkTimeoutError,
1029
478
  InvalidResponseError,
1030
- LivestreamScreen,
1031
479
  NetworkUnavailableError,
1032
480
  PICKLEBALL_EXPO_SDK_VERSION,
1033
481
  PermissionDeniedError,
482
+ PickleballAutoPrepare,
483
+ PickleballAutoResume,
1034
484
  PickleballExpoError,
1035
485
  PickleballLiveProvider,
486
+ RESUME_RETRY_DELAYS_MS,
1036
487
  RecordingFailureError,
1037
488
  SDK_TELEMETRY_EVENT_NAMES,
1038
489
  SdkDisabledError,
@@ -1041,14 +492,45 @@ export {
1041
492
  UnknownSdkError,
1042
493
  UnsupportedRuntimeError,
1043
494
  UnsupportedSdkError,
495
+ VIDEO_BITRATE_BPS,
496
+ applyAntiFlicker,
497
+ cameraSettingsKey,
498
+ classifyResumeStatus,
1044
499
  compareSemver,
500
+ createCameraGrantProvider,
501
+ createCameraSettingsStore,
1045
502
  createDefaultStorage,
503
+ createHttpResumeFetcher,
1046
504
  createHttpSessionProvider,
505
+ createRtmpRoomFactory,
506
+ createRtmpRuntime,
1047
507
  defaultLivestreamTheme,
1048
508
  errorFromCode,
509
+ getCameraCapabilities,
510
+ grantFromRefreshPayload,
1049
511
  isSdkRemoteConfig,
1050
512
  isSdkVersionSupported,
1051
513
  isSessionGoneError,
1052
514
  normalizeError,
1053
- useLivestream
515
+ parseCameraSettings,
516
+ requestNativePermissions,
517
+ requiredMbpsFor,
518
+ resumeBlocksArm,
519
+ resumeExternalSessionId,
520
+ resumeGrantMatches,
521
+ resumeRetryDelayMs,
522
+ rtmpRoomFactoryFor,
523
+ runUplinkCheck,
524
+ setExposureBias,
525
+ setExposureLocked,
526
+ setFocusLocked,
527
+ setTorch,
528
+ setZoom,
529
+ useCameraGrant,
530
+ useCameraSettings,
531
+ useLivestream,
532
+ useStandbySession,
533
+ verdictFor,
534
+ withLocalVideoQuality,
535
+ withResume
1054
536
  };