@pickleball/expo-sdk 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app.plugin.js +6 -0
  5. package/dist/chunk-VHLUJQG5.js +17 -0
  6. package/dist/index.cjs +6284 -0
  7. package/dist/index.d.cts +1436 -0
  8. package/dist/index.d.ts +1436 -0
  9. package/dist/index.js +6186 -0
  10. package/dist/plugin.cjs +153 -0
  11. package/dist/plugin.d.cts +21 -0
  12. package/dist/plugin.d.ts +21 -0
  13. package/dist/plugin.js +131 -0
  14. package/dist/version.cjs +35 -0
  15. package/dist/version.d.cts +4 -0
  16. package/dist/version.d.ts +4 -0
  17. package/dist/version.js +6 -0
  18. package/package.json +126 -0
  19. package/src/contracts.ts +211 -0
  20. package/src/engine.ts +1332 -0
  21. package/src/errors.ts +128 -0
  22. package/src/http-session-provider.ts +259 -0
  23. package/src/index.ts +16 -0
  24. package/src/livekit-room-adapter.ts +353 -0
  25. package/src/livestream-screen.tsx +980 -0
  26. package/src/match/contracts.ts +137 -0
  27. package/src/match/engine.ts +702 -0
  28. package/src/match/persistence/default-store.native.ts +20 -0
  29. package/src/match/persistence/default-store.ts +11 -0
  30. package/src/match/persistence/index.ts +6 -0
  31. package/src/match/persistence/memory-driver.ts +255 -0
  32. package/src/match/persistence/migrations.ts +40 -0
  33. package/src/match/persistence/sqlite-driver.ts +348 -0
  34. package/src/match/persistence/store.ts +497 -0
  35. package/src/match/persistence/types.ts +98 -0
  36. package/src/match/provider.tsx +728 -0
  37. package/src/match/remote/deduplicator.ts +54 -0
  38. package/src/match/remote/encoding.ts +12 -0
  39. package/src/match/remote/hid-gesture.ts +61 -0
  40. package/src/match/remote/index.ts +5 -0
  41. package/src/match/remote/native-service.ts +62 -0
  42. package/src/match/remote/parser.ts +134 -0
  43. package/src/match/remote/types.ts +116 -0
  44. package/src/match/replay.ts +102 -0
  45. package/src/match/state.ts +72 -0
  46. package/src/match/validation.ts +375 -0
  47. package/src/match/voice/assets/court-left.wav +0 -0
  48. package/src/match/voice/assets/court-right.wav +0 -0
  49. package/src/match/voice/assets/court-switch.wav +0 -0
  50. package/src/match/voice/assets/game-point.wav +0 -0
  51. package/src/match/voice/assets/game-won.wav +0 -0
  52. package/src/match/voice/assets/match-won.wav +0 -0
  53. package/src/match/voice/assets/number-0.wav +0 -0
  54. package/src/match/voice/assets/number-1.wav +0 -0
  55. package/src/match/voice/assets/number-10.wav +0 -0
  56. package/src/match/voice/assets/number-11.wav +0 -0
  57. package/src/match/voice/assets/number-12.wav +0 -0
  58. package/src/match/voice/assets/number-13.wav +0 -0
  59. package/src/match/voice/assets/number-14.wav +0 -0
  60. package/src/match/voice/assets/number-15.wav +0 -0
  61. package/src/match/voice/assets/number-16.wav +0 -0
  62. package/src/match/voice/assets/number-17.wav +0 -0
  63. package/src/match/voice/assets/number-18.wav +0 -0
  64. package/src/match/voice/assets/number-19.wav +0 -0
  65. package/src/match/voice/assets/number-2.wav +0 -0
  66. package/src/match/voice/assets/number-20.wav +0 -0
  67. package/src/match/voice/assets/number-3.wav +0 -0
  68. package/src/match/voice/assets/number-30.wav +0 -0
  69. package/src/match/voice/assets/number-4.wav +0 -0
  70. package/src/match/voice/assets/number-40.wav +0 -0
  71. package/src/match/voice/assets/number-5.wav +0 -0
  72. package/src/match/voice/assets/number-50.wav +0 -0
  73. package/src/match/voice/assets/number-6.wav +0 -0
  74. package/src/match/voice/assets/number-60.wav +0 -0
  75. package/src/match/voice/assets/number-7.wav +0 -0
  76. package/src/match/voice/assets/number-70.wav +0 -0
  77. package/src/match/voice/assets/number-8.wav +0 -0
  78. package/src/match/voice/assets/number-80.wav +0 -0
  79. package/src/match/voice/assets/number-9.wav +0 -0
  80. package/src/match/voice/assets/number-90.wav +0 -0
  81. package/src/match/voice/assets/number-lam.wav +0 -0
  82. package/src/match/voice/assets/number-mot.wav +0 -0
  83. package/src/match/voice/assets/score-deu-0.wav +0 -0
  84. package/src/match/voice/assets/score-deu-1.wav +0 -0
  85. package/src/match/voice/assets/score-deu-10.wav +0 -0
  86. package/src/match/voice/assets/score-deu-11.wav +0 -0
  87. package/src/match/voice/assets/score-deu-12.wav +0 -0
  88. package/src/match/voice/assets/score-deu-13.wav +0 -0
  89. package/src/match/voice/assets/score-deu-14.wav +0 -0
  90. package/src/match/voice/assets/score-deu-15.wav +0 -0
  91. package/src/match/voice/assets/score-deu-16.wav +0 -0
  92. package/src/match/voice/assets/score-deu-17.wav +0 -0
  93. package/src/match/voice/assets/score-deu-18.wav +0 -0
  94. package/src/match/voice/assets/score-deu-19.wav +0 -0
  95. package/src/match/voice/assets/score-deu-2.wav +0 -0
  96. package/src/match/voice/assets/score-deu-20.wav +0 -0
  97. package/src/match/voice/assets/score-deu-3.wav +0 -0
  98. package/src/match/voice/assets/score-deu-30.wav +0 -0
  99. package/src/match/voice/assets/score-deu-4.wav +0 -0
  100. package/src/match/voice/assets/score-deu-40.wav +0 -0
  101. package/src/match/voice/assets/score-deu-5.wav +0 -0
  102. package/src/match/voice/assets/score-deu-50.wav +0 -0
  103. package/src/match/voice/assets/score-deu-6.wav +0 -0
  104. package/src/match/voice/assets/score-deu-60.wav +0 -0
  105. package/src/match/voice/assets/score-deu-7.wav +0 -0
  106. package/src/match/voice/assets/score-deu-70.wav +0 -0
  107. package/src/match/voice/assets/score-deu-8.wav +0 -0
  108. package/src/match/voice/assets/score-deu-80.wav +0 -0
  109. package/src/match/voice/assets/score-deu-9.wav +0 -0
  110. package/src/match/voice/assets/score-deu-90.wav +0 -0
  111. package/src/match/voice/assets/score-deu-lam.wav +0 -0
  112. package/src/match/voice/assets/score-deu-mot.wav +0 -0
  113. package/src/match/voice/assets/server-1.wav +0 -0
  114. package/src/match/voice/assets/server-2.wav +0 -0
  115. package/src/match/voice/assets/service-change.wav +0 -0
  116. package/src/match/voice/assets/test-speaker.wav +0 -0
  117. package/src/match/voice/audio.ts +231 -0
  118. package/src/match/voice/bundled-assets.native.ts +47 -0
  119. package/src/match/voice/bundled-assets.ts +3 -0
  120. package/src/match/voice/gemini-engine.ts +167 -0
  121. package/src/match/voice/index.ts +7 -0
  122. package/src/match/voice/phrases.ts +185 -0
  123. package/src/match/voice/scheduler.ts +205 -0
  124. package/src/match/voice/service.ts +147 -0
  125. package/src/match/voice/types.ts +118 -0
  126. package/src/native-runtime.ts +241 -0
  127. package/src/plugin.ts +145 -0
  128. package/src/provider.tsx +176 -0
  129. package/src/version.ts +4 -0
@@ -0,0 +1,728 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react";
9
+ import type {
10
+ CourtSide,
11
+ MatchCorrection,
12
+ MatchCreateInput,
13
+ MatchEvent,
14
+ MatchInputSource,
15
+ MatchSnapshot,
16
+ } from "./contracts";
17
+ import {
18
+ awardRally,
19
+ confirmCourtSwitch as makeCourtSwitchConfirmation,
20
+ correctMatch,
21
+ endMatch as makeEndMatch,
22
+ } from "./engine";
23
+ import { createDefaultMatchEventStore } from "./persistence/default-store";
24
+ import type { MatchSummary, PersistedMatch } from "./persistence/types";
25
+ import { BTHomeRemoteDeduplicator } from "./remote/deduplicator";
26
+ import { decodeBase64Bytes } from "./remote/encoding";
27
+ import { HidVolumeGestureRecognizer } from "./remote/hid-gesture";
28
+ import { createDefaultRemoteService } from "./remote/native-service";
29
+ import { parseBTHomeV2Advertisement } from "./remote/parser";
30
+ import {
31
+ RemoteError,
32
+ type RemoteBinding,
33
+ DEFAULT_REMOTE_ACTION_MAPPING,
34
+ type RemoteActionMapping,
35
+ type RemoteHidVolumeEvent,
36
+ type RemoteHidGestureMapping,
37
+ type RemoteInputMode,
38
+ type RemoteMappedAction,
39
+ type RemoteNativeState,
40
+ type RemoteScanAdvertisement,
41
+ type RemoteService,
42
+ type RemoteState,
43
+ } from "./remote/types";
44
+ import { undoLastMatchEvent } from "./replay";
45
+ import { createDefaultMatchVoiceService } from "./voice/service";
46
+ import type {
47
+ MatchVoiceService,
48
+ VoiceAudioMode,
49
+ VoiceRouteState,
50
+ VoiceTtsOptions,
51
+ } from "./voice/types";
52
+
53
+ export interface PickleballMatchStore {
54
+ initialize(): Promise<void>;
55
+ createMatch(input: MatchCreateInput, source?: MatchInputSource): Promise<PersistedMatch>;
56
+ appendEvent(currentSnapshot: MatchSnapshot, event: MatchEvent): Promise<PersistedMatch>;
57
+ resumeMatch(matchId: string): Promise<PersistedMatch | null>;
58
+ resumeActiveMatch(): Promise<PersistedMatch | null>;
59
+ listMatches(): Promise<readonly MatchSummary[]>;
60
+ archiveMatch(matchId: string): Promise<void>;
61
+ getRemoteBinding?(): Promise<RemoteBinding | null>;
62
+ setRemoteBinding?(binding: RemoteBinding | null): Promise<void>;
63
+ }
64
+
65
+ export class NoActiveMatchError extends Error {
66
+ readonly name = "NoActiveMatchError";
67
+ readonly code = "NO_ACTIVE_MATCH";
68
+
69
+ constructor() {
70
+ super("No pickleball match is loaded");
71
+ }
72
+ }
73
+
74
+ /**
75
+ * State-changing actions reject with NoActiveMatchError when no match is loaded.
76
+ * Commands the engine deems ineligible are ignored and resolve the current snapshot.
77
+ */
78
+ export interface PickleballMatchContextValue {
79
+ readonly snapshot: MatchSnapshot | null;
80
+ readonly events: readonly MatchEvent[];
81
+ readonly loading: boolean;
82
+ readonly error: Error | null;
83
+ readonly voiceError: Error | null;
84
+ readonly audioState: VoiceRouteState;
85
+ readonly remoteState: RemoteState;
86
+ createMatch(input: MatchCreateInput, source?: MatchInputSource): Promise<MatchSnapshot>;
87
+ resumeMatch(matchId?: string): Promise<MatchSnapshot | null>;
88
+ awardRallyToSide(side: CourtSide, source?: MatchInputSource): Promise<MatchSnapshot>;
89
+ undo(source?: MatchInputSource): Promise<MatchSnapshot>;
90
+ correctState(
91
+ correction: MatchCorrection,
92
+ source?: MatchInputSource,
93
+ ): Promise<MatchSnapshot>;
94
+ confirmCourtSwitch(source?: MatchInputSource): Promise<MatchSnapshot>;
95
+ endMatch(reason: string, source?: MatchInputSource): Promise<MatchSnapshot>;
96
+ testSpeaker(): Promise<void>;
97
+ replayAnnouncement(): Promise<void>;
98
+ setVoiceAudioMode(mode: VoiceAudioMode): Promise<void>;
99
+ bindRemote(binding: RemoteBinding): Promise<void>;
100
+ unbindRemote(): Promise<void>;
101
+ startRemoteScan(): Promise<void>;
102
+ stopRemoteScan(): Promise<void>;
103
+ startHidCapture(): Promise<void>;
104
+ stopHidCapture(): Promise<void>;
105
+ }
106
+
107
+ export interface PickleballMatchProviderProps {
108
+ readonly children?: ReactNode;
109
+ /** Fixed for this provider instance. Remount to use a different local store. */
110
+ readonly store?: PickleballMatchStore;
111
+ /** Fixed for this provider instance. Remount to use a different factory. */
112
+ readonly storeFactory?: () => Promise<PickleballMatchStore>;
113
+ /** Resumes the active local match on mount. Disable for hosts that resume explicitly. */
114
+ readonly autoResume?: boolean;
115
+ readonly onError?: (error: Error) => void | Promise<void>;
116
+ /** Fixed for this provider instance. Primarily useful for custom storage and tests. */
117
+ readonly voiceService?: MatchVoiceService;
118
+ /** Initial audio session mode. Defaults to live so mounting never disables capture. */
119
+ readonly voiceAudioMode?: VoiceAudioMode;
120
+ /** Cloud TTS endpoint for announcements. Without it only bundled WAVs play. Fixed for this provider instance. */
121
+ readonly voiceTts?: VoiceTtsOptions;
122
+ /** Fixed for this provider instance. Primarily useful for tests and custom native bridges. */
123
+ readonly remoteService?: RemoteService;
124
+ /** Foreground silence before a bound remote becomes outOfRange. */
125
+ readonly remoteOutOfRangeTimeoutMs?: number;
126
+ readonly remoteInputMode?: RemoteInputMode;
127
+ readonly remoteActionMapping?: Partial<RemoteActionMapping>;
128
+ /** Optional single/double/hold recognizer for one calibrated HID volume button. */
129
+ readonly hidGestureMapping?: RemoteHidGestureMapping;
130
+ readonly consumeHidVolumeKeys?: boolean;
131
+ }
132
+
133
+ const INITIAL_REMOTE_STATE: RemoteState = {
134
+ status: "idle",
135
+ permission: "unknown",
136
+ binding: null,
137
+ candidate: null,
138
+ lastSeenAt: null,
139
+ error: null,
140
+ hidCaptureArmed: false,
141
+ lastInputSource: null,
142
+ lastHidEvent: null,
143
+ };
144
+
145
+ const MatchContext = React.createContext<PickleballMatchContextValue | null>(null);
146
+
147
+ export function PickleballMatchProvider({
148
+ children,
149
+ store,
150
+ storeFactory,
151
+ autoResume = true,
152
+ onError,
153
+ voiceService,
154
+ voiceAudioMode = "live",
155
+ voiceTts,
156
+ remoteService,
157
+ remoteOutOfRangeTimeoutMs = 5_000,
158
+ remoteInputMode = "bthome",
159
+ remoteActionMapping,
160
+ hidGestureMapping,
161
+ consumeHidVolumeKeys = true,
162
+ }: PickleballMatchProviderProps) {
163
+ const [snapshot, setSnapshot] = useState<MatchSnapshot | null>(null);
164
+ const [events, setEvents] = useState<readonly MatchEvent[]>([]);
165
+ const [loading, setLoading] = useState(autoResume);
166
+ const [error, setError] = useState<Error | null>(null);
167
+ const [voiceError, setVoiceError] = useState<Error | null>(null);
168
+ const [audioState, setAudioState] = useState<VoiceRouteState>(
169
+ voiceService?.audioState ?? {
170
+ route: "system",
171
+ interrupted: false,
172
+ disconnectedReason: null,
173
+ },
174
+ );
175
+ const [remoteState, setRemoteState] = useState<RemoteState>(INITIAL_REMOTE_STATE);
176
+ const snapshotRef = useRef<MatchSnapshot | null>(null);
177
+ const eventsRef = useRef<readonly MatchEvent[]>([]);
178
+ const queueRef = useRef<Promise<void>>(Promise.resolve());
179
+ const mountedRef = useRef(true);
180
+ const didAutoResumeRef = useRef(false);
181
+ const autoResumeOnMountRef = useRef(autoResume);
182
+ const onErrorRef = useRef(onError);
183
+ onErrorRef.current = onError;
184
+ const storeConfigurationRef = useRef({ store, storeFactory });
185
+ const storePromiseRef = useRef<Promise<PickleballMatchStore> | null>(null);
186
+ const voiceServiceRef = useRef<MatchVoiceService | null>(voiceService ?? null);
187
+ const voiceEffectGenerationRef = useRef(0);
188
+ const remoteServiceRef = useRef<RemoteService | null>(remoteService ?? null);
189
+ const remoteBindingRef = useRef<RemoteBinding | null>(null);
190
+ const remoteScanActiveRef = useRef(false);
191
+ const remoteLifecycleQueueRef = useRef<Promise<void>>(Promise.resolve());
192
+ const remoteUnsubscribersRef = useRef<readonly (() => void)[]>([]);
193
+ const remoteDedupeRef = useRef(new BTHomeRemoteDeduplicator());
194
+ const remoteRangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
195
+ const handleRemoteAdvertisementRef = useRef<(event: RemoteScanAdvertisement) => void>(() => undefined);
196
+ const handleRemoteNativeStateRef = useRef<(event: RemoteNativeState) => void>(() => undefined);
197
+ const handleHidVolumeRef = useRef<(event: RemoteHidVolumeEvent) => void>(() => undefined);
198
+ const handleRemoteMappedActionRef = useRef<(action: RemoteMappedAction) => void>(() => undefined);
199
+ const hidGestureRecognizerRef = useRef<HidVolumeGestureRecognizer | null>(null);
200
+ const resolvedRemoteMapping = useMemo(
201
+ () => ({ ...DEFAULT_REMOTE_ACTION_MAPPING, ...remoteActionMapping }),
202
+ [remoteActionMapping],
203
+ );
204
+
205
+ const reportVoiceError = useCallback((caught: unknown) => {
206
+ const operationError =
207
+ caught instanceof Error ? caught : new Error("Unknown voice operation failure");
208
+ if (mountedRef.current) setVoiceError(operationError);
209
+ }, []);
210
+
211
+ const voiceTtsRef = useRef(voiceTts);
212
+ const requireVoiceService = useCallback(() => {
213
+ voiceServiceRef.current ??= createDefaultMatchVoiceService({
214
+ audioMode: voiceAudioMode,
215
+ tts: voiceTtsRef.current,
216
+ });
217
+ return voiceServiceRef.current;
218
+ }, [voiceAudioMode]);
219
+
220
+ const requireRemoteService = useCallback(() => {
221
+ remoteServiceRef.current ??= createDefaultRemoteService();
222
+ return remoteServiceRef.current;
223
+ }, []);
224
+
225
+ const getStore = useCallback(() => {
226
+ if (!storePromiseRef.current) {
227
+ const configuration = storeConfigurationRef.current;
228
+ const pending = Promise.resolve().then(async () => {
229
+ const persistence = configuration.store
230
+ ? configuration.store
231
+ : configuration.storeFactory
232
+ ? await configuration.storeFactory()
233
+ : await createDefaultMatchEventStore();
234
+ await persistence.initialize();
235
+ return persistence;
236
+ });
237
+ storePromiseRef.current = pending;
238
+ void pending.catch(() => {
239
+ if (storePromiseRef.current === pending) storePromiseRef.current = null;
240
+ });
241
+ }
242
+ return storePromiseRef.current;
243
+ }, []);
244
+
245
+ const publish = useCallback((match: PersistedMatch | null) => {
246
+ snapshotRef.current = match?.snapshot ?? null;
247
+ eventsRef.current = match?.events ?? [];
248
+ if (mountedRef.current) {
249
+ setSnapshot(snapshotRef.current);
250
+ setEvents(eventsRef.current);
251
+ }
252
+ }, []);
253
+
254
+ const enqueue = useCallback(<T,>(work: () => Promise<T>): Promise<T> => {
255
+ const run = async () => {
256
+ if (mountedRef.current) {
257
+ setLoading(true);
258
+ setError(null);
259
+ }
260
+ try {
261
+ return await work();
262
+ } catch (caught) {
263
+ const operationError =
264
+ caught instanceof Error ? caught : new Error("Unknown match operation failure");
265
+ if (mountedRef.current) setError(operationError);
266
+ try {
267
+ const observerResult = onErrorRef.current?.(operationError);
268
+ if (observerResult) void Promise.resolve(observerResult).catch(() => undefined);
269
+ } catch {
270
+ // Observers cannot replace the operation failure seen by the caller.
271
+ }
272
+ throw operationError;
273
+ } finally {
274
+ if (mountedRef.current) setLoading(false);
275
+ }
276
+ };
277
+ const result = queueRef.current.then(run, run);
278
+ queueRef.current = result.then(
279
+ () => undefined,
280
+ () => undefined,
281
+ );
282
+ return result;
283
+ }, []);
284
+
285
+ const requireSnapshot = useCallback(() => {
286
+ if (!snapshotRef.current) throw new NoActiveMatchError();
287
+ return snapshotRef.current;
288
+ }, []);
289
+
290
+ const commit = useCallback(
291
+ (makeEvent: (current: MatchSnapshot) => MatchEvent | null) =>
292
+ enqueue(async () => {
293
+ const current = requireSnapshot();
294
+ const event = makeEvent(current);
295
+ if (!event) return current;
296
+ const persistence = await getStore();
297
+ const committed = await persistence.appendEvent(current, event);
298
+ publish(committed);
299
+ if (event.type === "rallyAwarded" || event.type === "courtSwitchConfirmed") {
300
+ try {
301
+ requireVoiceService().announce(current, committed.snapshot, event);
302
+ } catch (voiceFailure) {
303
+ reportVoiceError(voiceFailure);
304
+ }
305
+ }
306
+ return committed.snapshot;
307
+ }),
308
+ [enqueue, getStore, publish, reportVoiceError, requireSnapshot, requireVoiceService],
309
+ );
310
+
311
+ const createMatchAction = useCallback(
312
+ (input: MatchCreateInput, source: MatchInputSource = "local") =>
313
+ enqueue(async () => {
314
+ const persistence = await getStore();
315
+ const created = await persistence.createMatch(input, source);
316
+ publish(created);
317
+ return created.snapshot;
318
+ }),
319
+ [enqueue, getStore, publish],
320
+ );
321
+
322
+ const resumeMatchAction = useCallback(
323
+ (matchId?: string) =>
324
+ enqueue(async () => {
325
+ const persistence = await getStore();
326
+ const resumed = matchId
327
+ ? await persistence.resumeMatch(matchId)
328
+ : await persistence.resumeActiveMatch();
329
+ publish(resumed);
330
+ return resumed?.snapshot ?? null;
331
+ }),
332
+ [enqueue, getStore, publish],
333
+ );
334
+
335
+ const awardRallyToSide = useCallback(
336
+ (side: CourtSide, source: MatchInputSource = "local") =>
337
+ commit((current) => awardRally(current, side, source)),
338
+ [commit],
339
+ );
340
+
341
+ const undo = useCallback(
342
+ (source: MatchInputSource = "local") =>
343
+ commit(() => undoLastMatchEvent(eventsRef.current, source)),
344
+ [commit],
345
+ );
346
+
347
+ const correctState = useCallback(
348
+ (correction: MatchCorrection, source: MatchInputSource = "local") =>
349
+ commit((current) => correctMatch(current, correction, source)),
350
+ [commit],
351
+ );
352
+
353
+ const confirmCourtSwitch = useCallback(
354
+ (source: MatchInputSource = "local") =>
355
+ commit((current) => makeCourtSwitchConfirmation(current, source)),
356
+ [commit],
357
+ );
358
+
359
+ const endMatch = useCallback(
360
+ (reason: string, source: MatchInputSource = "local") =>
361
+ commit((current) => makeEndMatch(current, reason, source)),
362
+ [commit],
363
+ );
364
+
365
+ const runVoiceAction = useCallback(
366
+ async (action: (service: MatchVoiceService) => Promise<void>) => {
367
+ try {
368
+ setVoiceError(null);
369
+ await action(requireVoiceService());
370
+ } catch (caught) {
371
+ reportVoiceError(caught);
372
+ throw caught;
373
+ }
374
+ },
375
+ [reportVoiceError, requireVoiceService],
376
+ );
377
+
378
+ const testSpeaker = useCallback(
379
+ () => runVoiceAction((service) => service.testSpeaker()),
380
+ [runVoiceAction],
381
+ );
382
+ const replayAnnouncement = useCallback(
383
+ () => runVoiceAction((service) => service.replayAnnouncement()),
384
+ [runVoiceAction],
385
+ );
386
+ const setVoiceAudioMode = useCallback(
387
+ (mode: VoiceAudioMode) =>
388
+ runVoiceAction(async (service) => {
389
+ if (!service.configureAudioMode) {
390
+ throw new Error("The injected voice service cannot change audio mode");
391
+ }
392
+ await service.configureAudioMode(mode);
393
+ }),
394
+ [runVoiceAction],
395
+ );
396
+
397
+ const publishRemote = useCallback((update: (current: RemoteState) => RemoteState) => {
398
+ if (mountedRef.current) setRemoteState(update);
399
+ }, []);
400
+
401
+ const clearRemoteRangeTimer = useCallback(() => {
402
+ if (remoteRangeTimerRef.current) clearTimeout(remoteRangeTimerRef.current);
403
+ remoteRangeTimerRef.current = null;
404
+ }, []);
405
+
406
+ const armRemoteRangeTimer = useCallback(() => {
407
+ clearRemoteRangeTimer();
408
+ remoteRangeTimerRef.current = setTimeout(() => {
409
+ if (!remoteScanActiveRef.current || !remoteBindingRef.current) return;
410
+ publishRemote((current) => ({ ...current, status: "outOfRange" }));
411
+ }, remoteOutOfRangeTimeoutMs);
412
+ }, [clearRemoteRangeTimer, publishRemote, remoteOutOfRangeTimeoutMs]);
413
+
414
+ const bindRemote = useCallback(async (binding: RemoteBinding) => {
415
+ if (binding.deviceId.trim().length === 0) throw new Error("Remote identifier cannot be empty");
416
+ const persistence = await getStore();
417
+ if (!persistence.setRemoteBinding) throw new Error("Remote binding persistence is unavailable");
418
+ await persistence.setRemoteBinding(binding);
419
+ remoteBindingRef.current = binding;
420
+ remoteDedupeRef.current.clear();
421
+ publishRemote((current) => ({
422
+ ...current,
423
+ binding,
424
+ candidate: null,
425
+ status: remoteScanActiveRef.current ? "bound" : "idle",
426
+ error: null,
427
+ }));
428
+ }, [getStore, publishRemote]);
429
+
430
+ const unbindRemote = useCallback(async () => {
431
+ const persistence = await getStore();
432
+ if (!persistence.setRemoteBinding) throw new Error("Remote binding persistence is unavailable");
433
+ await persistence.setRemoteBinding(null);
434
+ remoteBindingRef.current = null;
435
+ remoteDedupeRef.current.clear();
436
+ clearRemoteRangeTimer();
437
+ publishRemote((current) => ({
438
+ ...current,
439
+ binding: null,
440
+ candidate: null,
441
+ lastSeenAt: null,
442
+ status: remoteScanActiveRef.current ? "scanning" : "idle",
443
+ error: null,
444
+ }));
445
+ }, [clearRemoteRangeTimer, getStore, publishRemote]);
446
+
447
+ const ensureRemoteSubscriptions = useCallback((service: RemoteService) => {
448
+ if (remoteUnsubscribersRef.current.length > 0) return;
449
+ remoteUnsubscribersRef.current = [
450
+ service.subscribeAdvertisement((event) => handleRemoteAdvertisementRef.current(event)),
451
+ service.subscribeState((event) => handleRemoteNativeStateRef.current(event)),
452
+ service.subscribeHidVolume((event) => handleHidVolumeRef.current(event)),
453
+ ];
454
+ }, []);
455
+
456
+ const startRemoteScan = useCallback(() => {
457
+ const run = remoteLifecycleQueueRef.current.then(async () => {
458
+ if (remoteScanActiveRef.current) return;
459
+ const persistence = await getStore();
460
+ const binding = persistence.getRemoteBinding
461
+ ? await persistence.getRemoteBinding()
462
+ : remoteBindingRef.current;
463
+ remoteBindingRef.current = binding;
464
+ const service = requireRemoteService();
465
+ ensureRemoteSubscriptions(service);
466
+ remoteScanActiveRef.current = true;
467
+ publishRemote((current) => ({
468
+ ...current,
469
+ binding,
470
+ status: binding ? "bound" : "scanning",
471
+ error: null,
472
+ }));
473
+ try {
474
+ await service.start();
475
+ } catch (caught) {
476
+ remoteScanActiveRef.current = false;
477
+ const failure = caught instanceof RemoteError
478
+ ? caught
479
+ : new RemoteError("scan_failed", "Unable to start BTHome scanning", { cause: caught });
480
+ publishRemote((current) => ({ ...current, status: "idle", error: failure }));
481
+ throw failure;
482
+ }
483
+ });
484
+ remoteLifecycleQueueRef.current = run.catch(() => undefined);
485
+ return run;
486
+ }, [ensureRemoteSubscriptions, getStore, publishRemote, requireRemoteService]);
487
+
488
+ const stopRemoteScan = useCallback(() => {
489
+ const run = remoteLifecycleQueueRef.current.then(async () => {
490
+ remoteScanActiveRef.current = false;
491
+ clearRemoteRangeTimer();
492
+ remoteDedupeRef.current.clear();
493
+ await remoteServiceRef.current?.stop();
494
+ for (const unsubscribe of remoteUnsubscribersRef.current) unsubscribe();
495
+ remoteUnsubscribersRef.current = [];
496
+ publishRemote((current) => ({ ...current, status: "idle", candidate: null }));
497
+ });
498
+ remoteLifecycleQueueRef.current = run.catch(() => undefined);
499
+ return run;
500
+ }, [clearRemoteRangeTimer, publishRemote]);
501
+
502
+ const startHidCapture = useCallback(async () => {
503
+ if (remoteInputMode === "bthome") throw new Error("HID volume input is disabled by remoteInputMode");
504
+ hidGestureRecognizerRef.current?.dispose();
505
+ hidGestureRecognizerRef.current = null;
506
+ const service = requireRemoteService();
507
+ ensureRemoteSubscriptions(service);
508
+ await service.startHidCapture({ consume: consumeHidVolumeKeys });
509
+ publishRemote((current) => ({ ...current, hidCaptureArmed: true, error: null }));
510
+ }, [consumeHidVolumeKeys, ensureRemoteSubscriptions, publishRemote, remoteInputMode, requireRemoteService]);
511
+
512
+ const stopHidCapture = useCallback(async () => {
513
+ hidGestureRecognizerRef.current?.dispose();
514
+ hidGestureRecognizerRef.current = null;
515
+ await remoteServiceRef.current?.stopHidCapture();
516
+ publishRemote((current) => ({ ...current, hidCaptureArmed: false }));
517
+ }, [publishRemote]);
518
+
519
+ handleRemoteMappedActionRef.current = (mapped) => {
520
+ const action = mapped === "left"
521
+ ? awardRallyToSide("left", "remote")
522
+ : mapped === "right"
523
+ ? awardRallyToSide("right", "remote")
524
+ : mapped === "undo"
525
+ ? undo("remote")
526
+ : null;
527
+ if (action) void action.catch(() => undefined);
528
+ };
529
+
530
+ handleHidVolumeRef.current = (event) => {
531
+ if (!mountedRef.current) return;
532
+ publishRemote((current) => ({
533
+ ...current, lastInputSource: "hidVolume", lastHidEvent: event,
534
+ }));
535
+ if (hidGestureMapping) {
536
+ hidGestureRecognizerRef.current ??= new HidVolumeGestureRecognizer({
537
+ ...hidGestureMapping,
538
+ onGesture: (gesture) => {
539
+ handleRemoteMappedActionRef.current(hidGestureMapping[gesture]);
540
+ },
541
+ });
542
+ hidGestureRecognizerRef.current.push(event);
543
+ return;
544
+ }
545
+ const mapped = event.longPress
546
+ ? resolvedRemoteMapping.longPress
547
+ : resolvedRemoteMapping[event.direction];
548
+ handleRemoteMappedActionRef.current(mapped);
549
+ };
550
+
551
+ handleRemoteNativeStateRef.current = (nativeState) => {
552
+ if (!remoteScanActiveRef.current) return;
553
+ if (nativeState.status === "permission") {
554
+ const error = nativeState.permission === "denied"
555
+ ? new RemoteError("permission_denied", "Bluetooth scan permission was denied")
556
+ : nativeState.permission === "restricted"
557
+ ? new RemoteError("permission_restricted", "Bluetooth access is restricted")
558
+ : null;
559
+ publishRemote((current) => ({ ...current, permission: nativeState.permission, error }));
560
+ return;
561
+ }
562
+ if (nativeState.status === "poweredOff" || nativeState.status === "unavailable") {
563
+ const error = nativeState.status === "poweredOff"
564
+ ? new RemoteError("bluetooth_powered_off", "Bluetooth is powered off")
565
+ : new RemoteError("bluetooth_unavailable", "Bluetooth LE is unavailable");
566
+ publishRemote((current) => ({ ...current, status: nativeState.status, error }));
567
+ return;
568
+ }
569
+ if (nativeState.status === "error") {
570
+ publishRemote((current) => ({
571
+ ...current,
572
+ error: new RemoteError("scan_failed", nativeState.message),
573
+ }));
574
+ }
575
+ };
576
+
577
+ handleRemoteAdvertisementRef.current = (advertisement) => {
578
+ if (!remoteScanActiveRef.current) return;
579
+ let parsed;
580
+ try {
581
+ parsed = parseBTHomeV2Advertisement({
582
+ deviceId: advertisement.deviceId,
583
+ serviceUuid: advertisement.serviceUuid,
584
+ serviceData: decodeBase64Bytes(advertisement.serviceData),
585
+ });
586
+ } catch {
587
+ return;
588
+ }
589
+ const binding = remoteBindingRef.current;
590
+ if (!binding) {
591
+ publishRemote((current) => ({
592
+ ...current,
593
+ candidate: {
594
+ deviceId: advertisement.deviceId,
595
+ ...(advertisement.name ? { label: advertisement.name } : {}),
596
+ rssi: advertisement.rssi,
597
+ lastSeenAt: advertisement.timestamp,
598
+ },
599
+ }));
600
+ return;
601
+ }
602
+ if (binding.deviceId !== advertisement.deviceId) return;
603
+ if (!remoteDedupeRef.current.accept(advertisement.deviceId, parsed, advertisement.timestamp)) return;
604
+ armRemoteRangeTimer();
605
+ publishRemote((current) => ({
606
+ ...current,
607
+ status: "bound",
608
+ lastSeenAt: advertisement.timestamp,
609
+ lastInputSource: "bthome",
610
+ error: null,
611
+ }));
612
+ const action = parsed.button.action === "single"
613
+ ? awardRallyToSide("left", "remote")
614
+ : parsed.button.action === "double"
615
+ ? awardRallyToSide("right", "remote")
616
+ : undo("remote");
617
+ void action.catch(() => undefined);
618
+ };
619
+
620
+ useEffect(() => {
621
+ mountedRef.current = true;
622
+ if (!didAutoResumeRef.current && autoResumeOnMountRef.current) {
623
+ didAutoResumeRef.current = true;
624
+ void resumeMatchAction().catch(() => undefined);
625
+ }
626
+ return () => {
627
+ mountedRef.current = false;
628
+ remoteScanActiveRef.current = false;
629
+ hidGestureRecognizerRef.current?.dispose();
630
+ hidGestureRecognizerRef.current = null;
631
+ clearRemoteRangeTimer();
632
+ for (const unsubscribe of remoteUnsubscribersRef.current) unsubscribe();
633
+ remoteUnsubscribersRef.current = [];
634
+ const service = remoteServiceRef.current;
635
+ if (service) {
636
+ void Promise.resolve().then(async () => {
637
+ await service.stop();
638
+ await service.stopHidCapture();
639
+ await service.dispose();
640
+ }).catch(() => undefined);
641
+ }
642
+ };
643
+ }, [clearRemoteRangeTimer, resumeMatchAction]);
644
+
645
+ useEffect(() => {
646
+ const effectGeneration = ++voiceEffectGenerationRef.current;
647
+ const service = requireVoiceService();
648
+ const refresh = () => {
649
+ if (!mountedRef.current) return;
650
+ setAudioState(service.audioState);
651
+ if (service.lastError) setVoiceError(service.lastError);
652
+ };
653
+ const unsubscribe = service.subscribe(refresh);
654
+ refresh();
655
+ void service.initialize().then(refresh).catch(reportVoiceError);
656
+ return () => {
657
+ unsubscribe();
658
+ if (!voiceService) {
659
+ void Promise.resolve().then(async () => {
660
+ if (voiceEffectGenerationRef.current === effectGeneration) await service.dispose();
661
+ }).catch(() => undefined);
662
+ }
663
+ };
664
+ }, [reportVoiceError, requireVoiceService, voiceService]);
665
+
666
+ const value = useMemo<PickleballMatchContextValue>(
667
+ () => ({
668
+ snapshot,
669
+ events,
670
+ loading,
671
+ error,
672
+ voiceError,
673
+ audioState,
674
+ remoteState,
675
+ createMatch: createMatchAction,
676
+ resumeMatch: resumeMatchAction,
677
+ awardRallyToSide,
678
+ undo,
679
+ correctState,
680
+ confirmCourtSwitch,
681
+ endMatch,
682
+ testSpeaker,
683
+ replayAnnouncement,
684
+ setVoiceAudioMode,
685
+ bindRemote,
686
+ unbindRemote,
687
+ startRemoteScan,
688
+ stopRemoteScan,
689
+ startHidCapture,
690
+ stopHidCapture,
691
+ }),
692
+ [
693
+ snapshot,
694
+ events,
695
+ loading,
696
+ error,
697
+ voiceError,
698
+ audioState,
699
+ remoteState,
700
+ createMatchAction,
701
+ resumeMatchAction,
702
+ awardRallyToSide,
703
+ undo,
704
+ correctState,
705
+ confirmCourtSwitch,
706
+ endMatch,
707
+ testSpeaker,
708
+ replayAnnouncement,
709
+ setVoiceAudioMode,
710
+ bindRemote,
711
+ unbindRemote,
712
+ startRemoteScan,
713
+ stopRemoteScan,
714
+ startHidCapture,
715
+ stopHidCapture,
716
+ ],
717
+ );
718
+
719
+ return <MatchContext.Provider value={value}>{children}</MatchContext.Provider>;
720
+ }
721
+
722
+ export function usePickleballMatch(): PickleballMatchContextValue {
723
+ const value = React.use(MatchContext);
724
+ if (!value) {
725
+ throw new Error("usePickleballMatch must be used inside PickleballMatchProvider");
726
+ }
727
+ return value;
728
+ }