@pickleball/expo-sdk 0.1.1 → 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 (117) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +163 -40
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +47 -0
  6. package/dist/advanced.d.cts +3 -0
  7. package/dist/advanced.d.ts +3 -0
  8. package/dist/advanced.js +47 -0
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-2JHULJZ7.js +265 -0
  12. package/dist/chunk-62YV3PCD.cjs +139 -0
  13. package/dist/chunk-6F3HN6US.js +50 -0
  14. package/dist/chunk-6YE5BL3A.cjs +50 -0
  15. package/dist/chunk-7OX2DSKI.cjs +33 -0
  16. package/dist/chunk-7Z7TN5N3.js +318 -0
  17. package/dist/chunk-DHDX3ZOO.js +800 -0
  18. package/dist/chunk-GXQYMPHE.cjs +9 -0
  19. package/dist/chunk-IUIRVKJI.cjs +2290 -0
  20. package/dist/chunk-KFQGP6VL.js +33 -0
  21. package/dist/chunk-LCWGNGEN.cjs +265 -0
  22. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  23. package/dist/chunk-XGP2MN5R.js +2290 -0
  24. package/dist/chunk-XL4CISVQ.js +9 -0
  25. package/dist/chunk-YGSQ25UA.cjs +800 -0
  26. package/dist/chunk-ZSKSXOCZ.js +139 -0
  27. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  28. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  29. package/dist/device-agent/index.cjs +1427 -0
  30. package/dist/device-agent/index.d.cts +493 -0
  31. package/dist/device-agent/index.d.ts +493 -0
  32. package/dist/device-agent/index.js +1427 -0
  33. package/dist/engine-BSvSdCSE.d.cts +741 -0
  34. package/dist/engine-BSvSdCSE.d.ts +741 -0
  35. package/dist/index.cjs +426 -6174
  36. package/dist/index.d.cts +299 -1400
  37. package/dist/index.d.ts +299 -1400
  38. package/dist/index.js +413 -6063
  39. package/dist/match/index.cjs +2456 -0
  40. package/dist/match/index.d.cts +253 -0
  41. package/dist/match/index.d.ts +253 -0
  42. package/dist/match/index.js +2456 -0
  43. package/dist/match/remote/index.cjs +21 -0
  44. package/dist/match/remote/index.d.cts +67 -0
  45. package/dist/match/remote/index.d.ts +67 -0
  46. package/dist/match/remote/index.js +21 -0
  47. package/dist/match/voice/index.cjs +23 -0
  48. package/dist/match/voice/index.d.cts +114 -0
  49. package/dist/match/voice/index.d.ts +114 -0
  50. package/dist/match/voice/index.js +23 -0
  51. package/dist/plugin.cjs +52 -71
  52. package/dist/plugin.d.cts +28 -1
  53. package/dist/plugin.d.ts +28 -1
  54. package/dist/plugin.js +26 -23
  55. package/dist/realtime/index.cjs +31 -0
  56. package/dist/realtime/index.d.cts +15 -0
  57. package/dist/realtime/index.d.ts +15 -0
  58. package/dist/realtime/index.js +31 -0
  59. package/dist/types-CrmcX408.d.cts +176 -0
  60. package/dist/types-CrmcX408.d.ts +176 -0
  61. package/dist/types-DiKWjwvN.d.cts +96 -0
  62. package/dist/types-DiKWjwvN.d.ts +96 -0
  63. package/dist/ui/index.cjs +1297 -0
  64. package/dist/ui/index.d.cts +493 -0
  65. package/dist/ui/index.d.ts +493 -0
  66. package/dist/ui/index.js +1297 -0
  67. package/dist/version.cjs +4 -32
  68. package/dist/version.js +2 -1
  69. package/package.json +95 -15
  70. package/src/advanced.ts +49 -0
  71. package/src/auto-resume.tsx +259 -0
  72. package/src/camera-controls.ts +99 -0
  73. package/src/camera-grant-provider.ts +392 -0
  74. package/src/camera-settings.ts +133 -0
  75. package/src/contracts.ts +73 -0
  76. package/src/device-agent/agent.ts +696 -0
  77. package/src/device-agent/beat-policy.ts +46 -0
  78. package/src/device-agent/component.tsx +60 -0
  79. package/src/device-agent/convex-beat-transport.ts +140 -0
  80. package/src/device-agent/convex-command-subscription.ts +101 -0
  81. package/src/device-agent/convex-control-channel.ts +88 -0
  82. package/src/device-agent/http-transport.ts +164 -0
  83. package/src/device-agent/index.ts +26 -0
  84. package/src/device-agent/mqtt-channel.ts +379 -0
  85. package/src/device-agent/mqtt-codec.ts +347 -0
  86. package/src/device-agent/tournament.ts +352 -0
  87. package/src/engine.ts +601 -20
  88. package/src/errors.ts +16 -0
  89. package/src/http-session-provider.ts +21 -183
  90. package/src/index.ts +117 -14
  91. package/src/local-video-quality.ts +43 -0
  92. package/src/match/index.ts +29 -0
  93. package/src/match/provider.tsx +4 -2
  94. package/src/match/remote/hid-gesture.ts +10 -2
  95. package/src/match/remote/types.ts +6 -4
  96. package/src/match/voice/scheduler.ts +6 -1
  97. package/src/match/voice/service.ts +16 -4
  98. package/src/native-runtime.ts +283 -77
  99. package/src/plugin.ts +54 -20
  100. package/src/provider.tsx +129 -22
  101. package/src/realtime/index.ts +56 -0
  102. package/src/resume-policy.ts +85 -0
  103. package/src/resume.ts +132 -0
  104. package/src/rtmp-room-adapter.ts +348 -0
  105. package/src/session-grant.ts +256 -0
  106. package/src/ui/camera-screen.tsx +484 -0
  107. package/src/ui/controls-sheet.tsx +445 -0
  108. package/src/ui/copy.ts +326 -0
  109. package/src/ui/helpers.ts +105 -0
  110. package/src/ui/hooks.ts +59 -0
  111. package/src/ui/index.ts +42 -0
  112. package/src/ui/preview-view.tsx +39 -0
  113. package/src/ui/primitives.tsx +304 -0
  114. package/src/uplink-check.ts +173 -0
  115. package/dist/chunk-VHLUJQG5.js +0 -17
  116. package/src/livekit-room-adapter.ts +0 -353
  117. package/src/livestream-screen.tsx +0 -980
package/src/errors.ts CHANGED
@@ -126,3 +126,19 @@ export function normalizeError(error: unknown): PickleballExpoError {
126
126
  }
127
127
  return new UnknownSdkError();
128
128
  }
129
+
130
+ /**
131
+ * Phiên đã biến mất hẳn phía server (HTTP 404): bị watchdog dọn, thuộc
132
+ * deployment khác, hoặc ID không hợp lệ. Khác hẳn lỗi mạng/5xx — thử lại không
133
+ * bao giờ cứu được, nên caller phải bỏ dấu khôi phục thay vì chặn vĩnh viễn.
134
+ *
135
+ * Mã HTTP được gắn vào lỗi ở http-session-provider vì envelope trả về chỉ có
136
+ * code "UNKNOWN", không phân biệt được 404 với 5xx.
137
+ */
138
+ export function isSessionGoneError(error: unknown): boolean {
139
+ return (
140
+ typeof error === "object" &&
141
+ error !== null &&
142
+ (error as { httpStatus?: unknown }).httpStatus === 404
143
+ );
144
+ }
@@ -2,23 +2,20 @@ import type {
2
2
  BootstrapInput,
3
3
  EndLivestreamInput,
4
4
  LivestreamSessionProvider,
5
- SdkBootstrap,
6
- SdkSessionGrant,
7
- SdkSessionRefresh,
8
- SdkTelemetryCredentials,
9
5
  StartLivestreamInput,
10
6
  } from "./contracts";
11
- import { isSdkRemoteConfig } from "./contracts";
7
+ import { InvalidResponseError } from "./errors";
12
8
  import {
13
- ExpoSdkConfigurationError,
14
- ExpoSdkTimeoutError,
15
- InvalidResponseError,
16
- NetworkUnavailableError,
17
- PickleballExpoError,
18
- errorFromCode,
19
- } from "./errors";
9
+ createJsonPoster,
10
+ isBootstrap,
11
+ isSessionGrant,
12
+ isSessionRefresh,
13
+ isSessionStatus,
14
+ joinUrl,
15
+ normalizeBaseUrl,
16
+ type Fetch,
17
+ } from "./session-grant";
20
18
 
21
- type Fetch = typeof globalThis.fetch;
22
19
  type HeadersInput = Record<string, string>;
23
20
 
24
21
  export interface HttpSessionProviderPaths {
@@ -31,7 +28,11 @@ export interface HttpSessionProviderPaths {
31
28
  }
32
29
 
33
30
  export interface HttpSessionProviderOptions {
34
- /** Base URL of the partner-owned mobile proxy. Never the Pickleball API URL. */
31
+ /**
32
+ * Base URL của proxy do đối tác host (mô hình cũ) hoặc namespace first-party
33
+ * `/api/v2/device-app` (app fleet). Không bao giờ là URL PickLive kèm API key.
34
+ * App đối tác dùng camera grant thì dùng createCameraGrantProvider thay vì đây.
35
+ */
35
36
  baseUrl: string;
36
37
  fetch?: Fetch;
37
38
  headers?: HeadersInput | (() => HeadersInput | Promise<HeadersInput>);
@@ -53,48 +54,15 @@ export function createHttpSessionProvider(
53
54
  options: HttpSessionProviderOptions,
54
55
  ): LivestreamSessionProvider {
55
56
  const baseUrl = normalizeBaseUrl(options.baseUrl);
56
- const fetchImplementation = options.fetch ?? globalThis.fetch;
57
- if (typeof fetchImplementation !== "function") {
58
- throw new ExpoSdkConfigurationError("A Fetch API implementation is required");
59
- }
57
+ const postJson = createJsonPoster({ fetch: options.fetch, timeoutMs: options.timeoutMs });
60
58
  const paths = { ...defaultPaths, ...options.paths };
61
59
 
62
60
  async function post(path: string, body: unknown): Promise<unknown> {
63
- const controller = new AbortController();
64
- const timeout = setTimeout(
65
- () => controller.abort(),
66
- options.timeoutMs ?? 10_000,
67
- );
68
- try {
69
- const extraHeaders =
70
- typeof options.headers === "function"
71
- ? await options.headers()
72
- : (options.headers ?? {});
73
- const response = await fetchImplementation(joinUrl(baseUrl, path), {
74
- method: "POST",
75
- headers: { "Content-Type": "application/json", ...extraHeaders },
76
- body: JSON.stringify(body),
77
- signal: controller.signal,
78
- redirect: "error",
79
- });
80
- const envelope = await response.json().catch(() => {
81
- throw new InvalidResponseError();
82
- });
83
- if (!response.ok) throw parseProxyError(envelope);
84
- if (!isRecord(envelope) || !("data" in envelope)) {
85
- throw new InvalidResponseError();
86
- }
87
- return envelope.data;
88
- } catch (error) {
89
- if (controller.signal.aborted) throw new ExpoSdkTimeoutError();
90
- if (error instanceof PickleballExpoError) throw error;
91
- if (error instanceof TypeError) {
92
- throw new NetworkUnavailableError(undefined, { cause: error });
93
- }
94
- throw error;
95
- } finally {
96
- clearTimeout(timeout);
97
- }
61
+ const extraHeaders =
62
+ typeof options.headers === "function"
63
+ ? await options.headers()
64
+ : (options.headers ?? {});
65
+ return postJson(joinUrl(baseUrl, path), body, extraHeaders);
98
66
  }
99
67
 
100
68
  return {
@@ -127,133 +95,3 @@ export function createHttpSessionProvider(
127
95
  },
128
96
  };
129
97
  }
130
-
131
- declare const __DEV__: boolean | undefined;
132
-
133
- // RFC 1918 private ranges: 10/8, 172.16/12, 192.168/16.
134
- const PRIVATE_LAN_HOST =
135
- /^(?: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})$/;
136
-
137
- function normalizeBaseUrl(value: string) {
138
- try {
139
- const url = new URL(value);
140
- const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
141
- const secure = url.protocol === "https:";
142
- const localDevelopment =
143
- url.protocol === "http:" && loopbackHosts.has(url.hostname);
144
- // Dev bundle only (Metro/dev client): a device on the same network must
145
- // reach the dev proxy over plain HTTP at a private LAN address. Release
146
- // bundles compile __DEV__ to false, so production keeps the HTTPS rule.
147
- const devPrivateLan =
148
- typeof __DEV__ !== "undefined" &&
149
- __DEV__ === true &&
150
- url.protocol === "http:" &&
151
- PRIVATE_LAN_HOST.test(url.hostname);
152
- if (
153
- (!secure && !localDevelopment && !devPrivateLan) ||
154
- url.username ||
155
- url.password
156
- ) {
157
- throw new Error();
158
- }
159
- return value.replace(/\/+$/, "");
160
- } catch {
161
- throw new ExpoSdkConfigurationError(
162
- "baseUrl must use HTTPS (HTTP is allowed only for localhost loopback)",
163
- );
164
- }
165
- }
166
-
167
- function joinUrl(base: string, path: string) {
168
- return `${base}/${path.replace(/^\/+/, "")}`;
169
- }
170
-
171
- function parseProxyError(envelope: unknown) {
172
- if (
173
- isRecord(envelope) &&
174
- isRecord(envelope.error) &&
175
- typeof envelope.error.code === "string"
176
- ) {
177
- return errorFromCode(
178
- envelope.error.code,
179
- typeof envelope.error.message === "string"
180
- ? envelope.error.message
181
- : undefined,
182
- );
183
- }
184
- return new InvalidResponseError();
185
- }
186
-
187
- function isRecord(value: unknown): value is Record<string, unknown> {
188
- return typeof value === "object" && value !== null && !Array.isArray(value);
189
- }
190
-
191
- function isFiniteNumber(value: unknown): value is number {
192
- return typeof value === "number" && Number.isFinite(value);
193
- }
194
-
195
- function isBootstrap(value: unknown): value is SdkBootstrap {
196
- return (
197
- isRecord(value) &&
198
- isSdkRemoteConfig(value.config) &&
199
- isFiniteNumber(value.serverTime) &&
200
- typeof value.rolloutEnabled === "boolean" &&
201
- (value.message === undefined || typeof value.message === "string")
202
- );
203
- }
204
-
205
- function isTelemetry(value: unknown): value is SdkTelemetryCredentials {
206
- return (
207
- isRecord(value) &&
208
- typeof value.endpoint === "string" &&
209
- /^https:\/\//.test(value.endpoint) &&
210
- typeof value.token === "string" &&
211
- value.token.length > 0 &&
212
- isFiniteNumber(value.expiresAt)
213
- );
214
- }
215
-
216
- function isSessionGrant(value: unknown): value is SdkSessionGrant {
217
- return (
218
- isRecord(value) &&
219
- typeof value.sessionId === "string" &&
220
- value.sessionId.length > 0 &&
221
- typeof value.serverUrl === "string" &&
222
- /^wss:\/\//.test(value.serverUrl) &&
223
- typeof value.participantToken === "string" &&
224
- value.participantToken.length > 0 &&
225
- (value.playbackUrl === null || typeof value.playbackUrl === "string") &&
226
- isFiniteNumber(value.tokenExpiresAt) &&
227
- isTelemetry(value.telemetry) &&
228
- isSdkRemoteConfig(value.config)
229
- );
230
- }
231
-
232
- function isSessionStatus(
233
- value: unknown,
234
- ): value is { sessionId: string; status: string } {
235
- return (
236
- isRecord(value) &&
237
- typeof value.sessionId === "string" &&
238
- value.sessionId.length > 0 &&
239
- typeof value.status === "string" &&
240
- value.status.length > 0
241
- );
242
- }
243
-
244
- function isSessionRefresh(value: unknown): value is SdkSessionRefresh {
245
- if (!isRecord(value) || !isSdkRemoteConfig(value.config)) return false;
246
- if (
247
- value.participantToken !== undefined &&
248
- (typeof value.participantToken !== "string" || !value.participantToken)
249
- ) {
250
- return false;
251
- }
252
- if (
253
- value.tokenExpiresAt !== undefined &&
254
- !isFiniteNumber(value.tokenExpiresAt)
255
- ) {
256
- return false;
257
- }
258
- return value.telemetry === undefined || isTelemetry(value.telemetry);
259
- }
package/src/index.ts CHANGED
@@ -1,16 +1,119 @@
1
+ /**
2
+ * Entry gọn cho tích hợp livestream cơ bản (SDK 1.0: RTMP-only, camera grant).
3
+ * Các tính năng khác nằm ở subpath:
4
+ * @pickleball/expo-sdk/ui — màn hình camera + sheet cấu hình dựng sẵn
5
+ * @pickleball/expo-sdk/realtime — nhận lệnh qua websocket Convex (kéo `convex`)
6
+ * @pickleball/expo-sdk/match — scoreboard + persistence
7
+ * @pickleball/expo-sdk/match/voice — đọc điểm bằng loa
8
+ * @pickleball/expo-sdk/match/remote — remote BLE/HID
9
+ * @pickleball/expo-sdk/device-agent — điều khiển từ dashboard (app fleet)
10
+ * @pickleball/expo-sdk/advanced — engine internals, DI, mock/test
11
+ * Metro không tree-shake nên chỉ import subpath nào app thật sự dùng.
12
+ */
1
13
  export * from "./contracts";
2
- export * from "./match/contracts";
3
- export * from "./match/engine";
4
- export { replayMatchEvents, undoLastMatchEvent } from "./match/replay";
5
- export * from "./match/persistence";
6
- export * from "./match/provider";
7
- export * from "./match/remote";
8
- export * from "./match/voice";
9
14
  export * from "./errors";
10
- export * from "./engine";
11
- export * from "./http-session-provider";
12
- export * from "./livekit-room-adapter";
13
- export * from "./native-runtime";
14
- export * from "./provider";
15
- export * from "./livestream-screen";
16
- export * from "./version";
15
+ export {
16
+ PickleballLiveProvider,
17
+ useLivestream,
18
+ defaultLivestreamTheme,
19
+ type PickleballLiveProviderProps,
20
+ type LivestreamContextValue,
21
+ type LivestreamTheme,
22
+ } from "./provider";
23
+ export {
24
+ createHttpSessionProvider,
25
+ type HttpSessionProviderOptions,
26
+ type HttpSessionProviderPaths,
27
+ } from "./http-session-provider";
28
+ // Điện thoại đối tác — camera grant từ backend đối tác (ADR-0002)
29
+ export {
30
+ CAMERA_GRANT_STORAGE_KEY,
31
+ DEFAULT_LOCAL_CONFIG,
32
+ createCameraGrantProvider,
33
+ useCameraGrant,
34
+ type CameraGrantHandle,
35
+ type CameraGrantPhase,
36
+ type CameraGrantProvider,
37
+ type CameraGrantProviderOptions,
38
+ } from "./camera-grant-provider";
39
+ // Nhận lại phiên sau khi app bị kill
40
+ export {
41
+ createHttpResumeFetcher,
42
+ grantFromRefreshPayload,
43
+ withResume,
44
+ type HttpResumeFetcherOptions,
45
+ type ResumableSessionProvider,
46
+ type ResumeAttempt,
47
+ } from "./resume";
48
+ export {
49
+ RESUME_RETRY_DELAYS_MS,
50
+ classifyResumeStatus,
51
+ resumeBlocksArm,
52
+ resumeExternalSessionId,
53
+ resumeGrantMatches,
54
+ resumeRetryDelayMs,
55
+ type ResumeVerdict,
56
+ } from "./resume-policy";
57
+ export {
58
+ PickleballAutoPrepare,
59
+ PickleballAutoResume,
60
+ useStandbySession,
61
+ type PickleballAutoPrepareProps,
62
+ type PickleballAutoResumeProps,
63
+ type ResumeAttemptOutcome,
64
+ type UseStandbySessionOptions,
65
+ } from "./auto-resume";
66
+ export { withLocalVideoQuality } from "./local-video-quality";
67
+ // Cấu hình camera local + điều khiển runtime + đo mạng
68
+ export {
69
+ CAMERA_SETTINGS_STORAGE_KEY,
70
+ DEFAULT_CAMERA_SETTINGS,
71
+ cameraSettingsKey,
72
+ createCameraSettingsStore,
73
+ parseCameraSettings,
74
+ rtmpRoomFactoryFor,
75
+ useCameraSettings,
76
+ type AntiFlickerMode,
77
+ type CameraSettings,
78
+ type CameraSettingsStore,
79
+ type CameraSettingsStoreOptions,
80
+ } from "./camera-settings";
81
+ export {
82
+ applyAntiFlicker,
83
+ getCameraCapabilities,
84
+ requestNativePermissions,
85
+ setExposureBias,
86
+ setExposureLocked,
87
+ setFocusLocked,
88
+ setTorch,
89
+ setZoom,
90
+ type CameraCapabilities,
91
+ type CameraCapabilityRange,
92
+ } from "./camera-controls";
93
+ export {
94
+ AUDIO_BITRATE_BPS,
95
+ DEFAULT_UPLOAD_URL,
96
+ VIDEO_BITRATE_BPS,
97
+ requiredMbpsFor,
98
+ runUplinkCheck,
99
+ verdictFor,
100
+ type UplinkCheckOptions,
101
+ type UplinkCheckResult,
102
+ } from "./uplink-check";
103
+ // Runtime RTMP-only + roomFactory RTMP (mode mặc định cho app camera)
104
+ export { createDefaultStorage, createRtmpRoomFactory, createRtmpRuntime } from "./native-runtime";
105
+ // Type-only: xuất hiện trong LivestreamContextValue/ProviderProps
106
+ export type {
107
+ CameraDeviceInfo,
108
+ CaptureOrientation,
109
+ DeviceMetricsSample,
110
+ LivestreamSnapshot,
111
+ RoomAdapter,
112
+ SdkNetwork,
113
+ SdkRuntime,
114
+ SdkStorage,
115
+ SessionCommandChannel,
116
+ SessionCommandSubscription,
117
+ TelemetrySink,
118
+ } from "./engine";
119
+ export { PICKLEBALL_EXPO_SDK_VERSION } from "./version";
@@ -0,0 +1,43 @@
1
+ import type { LivestreamSessionProvider } from "./contracts";
2
+
3
+ /**
4
+ * Độ phân giải là lựa chọn LOCAL của máy — override `videoQuality` mà server trả
5
+ * trong bootstrap/start/refresh (engine truyền config.videoQuality vào
6
+ * room.preparePreview nên không cần sửa engine).
7
+ *
8
+ * Lúc TẠO phiên phải khai ngược lên server (`quality` trong StartLivestreamInput),
9
+ * nếu không bản ghi phiên nói dối: mặc định local 720 còn server 1080 — dashboard
10
+ * báo 1080 trong khi máy phát 720 suốt (18/08/2026). Chốt MỘT lần lúc start:
11
+ * gọi `resolution()` hai lần có thể ra hai giá trị khác nhau, khai một đằng dùng
12
+ * một nẻo thì tệ hơn là không khai.
13
+ */
14
+ export function withLocalVideoQuality<P extends LivestreamSessionProvider>(
15
+ provider: P,
16
+ resolution: () => 720 | 1080,
17
+ ): P {
18
+ // Spread giữ nguyên MỌI method khác của provider (resume/adoptResumeGrant của
19
+ // ResumableSessionProvider, getActiveGrant/subscribe của CameraGrantProvider)
20
+ // — bọc mà rơi mất chúng là đường nhận lại phiên gãy im lặng ở tầng type.
21
+ // Provider là object thuần (closure) nên spread đủ; class có method prototype
22
+ // thì không hỗ trợ.
23
+ return {
24
+ ...provider,
25
+ async bootstrap(input) {
26
+ const result = await provider.bootstrap(input);
27
+ return { ...result, config: { ...result.config, videoQuality: resolution() } };
28
+ },
29
+ async start(input) {
30
+ const quality = resolution();
31
+ const result = await provider.start({ ...input, quality });
32
+ return { ...result, config: { ...result.config, videoQuality: quality } };
33
+ },
34
+ async refresh(sessionId) {
35
+ const result = await provider.refresh(sessionId);
36
+ return { ...result, config: { ...result.config, videoQuality: resolution() } };
37
+ },
38
+ end: (input) => provider.end(input),
39
+ // publish/unpublish là optional trong contract — forward nguyên trạng.
40
+ publish: provider.publish ? (sessionId) => provider.publish!(sessionId) : undefined,
41
+ unpublish: provider.unpublish ? (sessionId) => provider.unpublish!(sessionId) : undefined,
42
+ } as P;
43
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Entry cho tính năng trận đấu: scoreboard engine, persistence SQLite và
3
+ * PickleballMatchProvider. Voice/remote có subpath riêng
4
+ * (@pickleball/expo-sdk/match/voice, /match/remote) — ở đây chỉ re-export
5
+ * type-only các kiểu xuất hiện trong props của provider.
6
+ */
7
+ export * from "./contracts";
8
+ export * from "./engine";
9
+ export { replayMatchEvents, undoLastMatchEvent } from "./replay";
10
+ export * from "./persistence";
11
+ export * from "./provider";
12
+ export type {
13
+ MatchVoiceService,
14
+ VoiceAudioMode,
15
+ VoiceRouteState,
16
+ VoiceTtsOptions,
17
+ } from "./voice/types";
18
+ export type {
19
+ RemoteActionMapping,
20
+ RemoteBinding,
21
+ RemoteHidGestureMapping,
22
+ RemoteHidVolumeEvent,
23
+ RemoteInputMode,
24
+ RemoteMappedAction,
25
+ RemoteNativeState,
26
+ RemoteScanAdvertisement,
27
+ RemoteService,
28
+ RemoteState,
29
+ } from "./remote/types";
@@ -523,7 +523,9 @@ export function PickleballMatchProvider({
523
523
  ? awardRallyToSide("right", "remote")
524
524
  : mapped === "undo"
525
525
  ? undo("remote")
526
- : null;
526
+ : mapped === "announce"
527
+ ? replayAnnouncement()
528
+ : null;
527
529
  if (action) void action.catch(() => undefined);
528
530
  };
529
531
 
@@ -536,7 +538,7 @@ export function PickleballMatchProvider({
536
538
  hidGestureRecognizerRef.current ??= new HidVolumeGestureRecognizer({
537
539
  ...hidGestureMapping,
538
540
  onGesture: (gesture) => {
539
- handleRemoteMappedActionRef.current(hidGestureMapping[gesture]);
541
+ handleRemoteMappedActionRef.current(hidGestureMapping[gesture] ?? "none");
540
542
  },
541
543
  });
542
544
  hidGestureRecognizerRef.current.push(event);
@@ -16,7 +16,7 @@ export class HidVolumeGestureRecognizer {
16
16
  constructor(private readonly options: HidVolumeGestureRecognizerOptions) {}
17
17
 
18
18
  push(event: RemoteHidVolumeEvent) {
19
- if (event.direction !== this.options.direction) return;
19
+ if (this.options.direction !== "any" && event.direction !== this.options.direction) return;
20
20
  if (
21
21
  this.lastTimestamp !== null &&
22
22
  event.timestamp - this.lastTimestamp > this.options.settleMs
@@ -43,7 +43,15 @@ export class HidVolumeGestureRecognizer {
43
43
  private finishBurst() {
44
44
  this.clearTimer();
45
45
  if (!this.resolved && this.count > 0) {
46
- this.options.onGesture(this.count === 1 ? "single" : "double");
46
+ this.options.onGesture(
47
+ this.count === 1
48
+ ? "single"
49
+ : this.count === 2
50
+ ? "double"
51
+ : this.count === 3
52
+ ? "triple"
53
+ : "quadruple",
54
+ );
47
55
  }
48
56
  this.reset();
49
57
  }
@@ -2,7 +2,7 @@ export const BTHOME_SERVICE_UUID = "FCD2" as const;
2
2
 
3
3
  export type RemoteButtonAction = "single" | "double" | "long";
4
4
  export type RemoteInputMode = "bthome" | "hidVolume" | "both";
5
- export type RemoteMappedAction = "left" | "right" | "undo" | "none";
5
+ export type RemoteMappedAction = "left" | "right" | "undo" | "announce" | "none";
6
6
  export interface RemoteActionMapping {
7
7
  readonly volumeUp: RemoteMappedAction;
8
8
  readonly volumeDown: RemoteMappedAction;
@@ -19,17 +19,19 @@ export interface RemoteHidVolumeEvent {
19
19
  readonly timestamp: number;
20
20
  }
21
21
 
22
- export type RemoteHidGesture = "single" | "double" | "hold";
22
+ export type RemoteHidGesture = "single" | "double" | "triple" | "quadruple" | "hold";
23
23
 
24
24
  export interface RemoteHidGestureMapping {
25
- /** Raw HID direction emitted by the calibrated remote button. */
26
- readonly direction: RemoteHidVolumeEvent["direction"];
25
+ /** Raw HID direction emitted by the calibrated remote button, or "any" for single-button remotes that alternate directions. */
26
+ readonly direction: RemoteHidVolumeEvent["direction"] | "any";
27
27
  /** Quiet window used to close one physical gesture burst. */
28
28
  readonly settleMs: number;
29
29
  /** Repeated volume changes required to classify a hold before the burst ends. */
30
30
  readonly holdEventCount: number;
31
31
  readonly single: RemoteMappedAction;
32
32
  readonly double: RemoteMappedAction;
33
+ readonly triple?: RemoteMappedAction;
34
+ readonly quadruple?: RemoteMappedAction;
33
35
  readonly hold: RemoteMappedAction;
34
36
  }
35
37
 
@@ -75,6 +75,7 @@ export class LatestAnnouncementScheduler {
75
75
  ]);
76
76
  if (generation !== this.generation) return;
77
77
 
78
+ let neuralFailure: unknown = null;
78
79
  try {
79
80
  const avail = await this.options.engine.availability();
80
81
  if (avail === "available") {
@@ -89,17 +90,21 @@ export class LatestAnnouncementScheduler {
89
90
  }
90
91
  } catch (error) {
91
92
  if (controller.signal.aborted || generation !== this.generation) return;
92
- this.report(error);
93
+ // Đọc bằng WAV offline vẫn chạy được — chỉ báo lỗi TTS khi fallback cũng hỏng.
94
+ neuralFailure = error;
93
95
  }
94
96
 
97
+ let fallbackPlayed = false;
95
98
  for (const source of planBundledFallback(plan)) {
96
99
  if (generation !== this.generation || controller.signal.aborted) return;
97
100
  try {
98
101
  await this.options.audio.play(source);
102
+ fallbackPlayed = true;
99
103
  } catch (error) {
100
104
  this.report(error);
101
105
  }
102
106
  }
107
+ if (neuralFailure && !fallbackPlayed) this.report(neuralFailure);
103
108
  }
104
109
 
105
110
  async replayLast(): Promise<void> {
@@ -56,11 +56,13 @@ export function createDefaultMatchVoiceService(
56
56
  (options.tts
57
57
  ? createGeminiVoiceEngine({ ...options.tts, generatedRoot: generatedDirectory() })
58
58
  : createUnavailableVoiceEngine());
59
+ let errorEpoch = 0;
59
60
  const scheduler = new LatestAnnouncementScheduler({
60
61
  engine,
61
62
  audio,
62
63
  onError(error) {
63
64
  lastError = error;
65
+ errorEpoch += 1;
64
66
  enqueueNotify();
65
67
  },
66
68
  });
@@ -118,10 +120,20 @@ export function createDefaultMatchVoiceService(
118
120
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
119
121
  announce(previous, current, event) {
120
122
  if (!stillActive()) return;
121
- void scheduler.enqueue(buildVietnameseAnnouncement(previous, current, event)).catch((error) => {
122
- lastError = error instanceof Error ? error : new Error("Announcement failed");
123
- notify();
124
- });
123
+ const epochBefore = errorEpoch;
124
+ void scheduler.enqueue(buildVietnameseAnnouncement(previous, current, event)).then(
125
+ () => {
126
+ // Lần đọc trọn vẹn (không lỗi mới nào bị báo) xoá lỗi voice cũ khỏi UI thay vì hiển thị mãi.
127
+ if (lastError && errorEpoch === epochBefore && stillActive()) {
128
+ lastError = null;
129
+ notify();
130
+ }
131
+ },
132
+ (error) => {
133
+ lastError = error instanceof Error ? error : new Error("Announcement failed");
134
+ notify();
135
+ },
136
+ );
125
137
  },
126
138
  replayAnnouncement: () => stillActive() ? scheduler.replayLast() : Promise.resolve(),
127
139
  testSpeaker: () => stillActive() ? scheduler.testSpeaker() : Promise.resolve(),