@pickleball/expo-sdk 0.1.1 → 0.2.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 (74) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +69 -0
  3. package/dist/advanced-BXDrDl1h.d.ts +316 -0
  4. package/dist/advanced-BdAa2Erv.d.cts +316 -0
  5. package/dist/advanced.cjs +35 -0
  6. package/dist/advanced.d.cts +3 -0
  7. package/dist/advanced.d.ts +3 -0
  8. package/dist/advanced.js +35 -0
  9. package/dist/chunk-2JHULJZ7.js +265 -0
  10. package/dist/chunk-6JOMESNT.js +9 -0
  11. package/dist/chunk-7OX2DSKI.cjs +33 -0
  12. package/dist/chunk-DHDX3ZOO.js +800 -0
  13. package/dist/chunk-FODE2W2H.js +1953 -0
  14. package/dist/chunk-KFQGP6VL.js +33 -0
  15. package/dist/chunk-LCWGNGEN.cjs +265 -0
  16. package/dist/chunk-TBPR2QND.cjs +1953 -0
  17. package/dist/chunk-TRLASNPQ.cjs +9 -0
  18. package/dist/chunk-YGSQ25UA.cjs +800 -0
  19. package/dist/device-agent/index.cjs +498 -0
  20. package/dist/device-agent/index.d.cts +157 -0
  21. package/dist/device-agent/index.d.ts +157 -0
  22. package/dist/device-agent/index.js +498 -0
  23. package/dist/engine-CCSSqPbZ.d.cts +516 -0
  24. package/dist/engine-CCSSqPbZ.d.ts +516 -0
  25. package/dist/index.cjs +339 -5569
  26. package/dist/index.d.cts +22 -1406
  27. package/dist/index.d.ts +22 -1406
  28. package/dist/index.js +269 -5401
  29. package/dist/match/index.cjs +2456 -0
  30. package/dist/match/index.d.cts +253 -0
  31. package/dist/match/index.d.ts +253 -0
  32. package/dist/match/index.js +2456 -0
  33. package/dist/match/remote/index.cjs +21 -0
  34. package/dist/match/remote/index.d.cts +67 -0
  35. package/dist/match/remote/index.d.ts +67 -0
  36. package/dist/match/remote/index.js +21 -0
  37. package/dist/match/voice/index.cjs +23 -0
  38. package/dist/match/voice/index.d.cts +114 -0
  39. package/dist/match/voice/index.d.ts +114 -0
  40. package/dist/match/voice/index.js +23 -0
  41. package/dist/plugin.cjs +53 -70
  42. package/dist/plugin.d.cts +13 -0
  43. package/dist/plugin.d.ts +13 -0
  44. package/dist/plugin.js +20 -15
  45. package/dist/types-CrmcX408.d.cts +176 -0
  46. package/dist/types-CrmcX408.d.ts +176 -0
  47. package/dist/types-DiKWjwvN.d.cts +96 -0
  48. package/dist/types-DiKWjwvN.d.ts +96 -0
  49. package/dist/version.cjs +4 -32
  50. package/dist/version.js +2 -1
  51. package/package.json +68 -4
  52. package/src/advanced.ts +47 -0
  53. package/src/device-agent/agent.ts +443 -0
  54. package/src/device-agent/component.tsx +49 -0
  55. package/src/device-agent/convex-control-channel.ts +82 -0
  56. package/src/device-agent/http-transport.ts +143 -0
  57. package/src/device-agent/index.ts +4 -0
  58. package/src/engine.ts +21 -5
  59. package/src/errors.ts +16 -0
  60. package/src/http-session-provider.ts +19 -13
  61. package/src/index.ts +38 -14
  62. package/src/livekit-room-adapter.ts +39 -2
  63. package/src/livestream-screen.tsx +26 -4
  64. package/src/match/index.ts +29 -0
  65. package/src/match/provider.tsx +4 -2
  66. package/src/match/remote/hid-gesture.ts +10 -2
  67. package/src/match/remote/types.ts +6 -4
  68. package/src/match/voice/scheduler.ts +6 -1
  69. package/src/match/voice/service.ts +16 -4
  70. package/src/native-runtime.ts +54 -4
  71. package/src/plugin.ts +35 -14
  72. package/src/provider.tsx +35 -4
  73. package/src/rtmp-room-adapter.ts +196 -0
  74. package/dist/chunk-VHLUJQG5.js +0 -17
@@ -0,0 +1,49 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useLivestreamHost } from "../provider";
3
+ import {
4
+ DeviceAgent,
5
+ type DeviceAgentDeviceInfo,
6
+ type DeviceAgentTransport,
7
+ type DeviceControlChannel,
8
+ type RemoteStartConfig,
9
+ } from "./agent";
10
+
11
+ /**
12
+ * Bật device-agent: đăng ký thiết bị + heartbeat presence khi app mở, nhận
13
+ * lệnh start/stop livestream từ dashboard qua kênh điều khiển on-demand.
14
+ * Render bên trong PickleballLiveProvider (thường là child đầu tiên).
15
+ * Tách khỏi provider để app không dùng tính năng này không bundle convex.
16
+ */
17
+ export interface PickleballDeviceAgentProps {
18
+ transport: DeviceAgentTransport;
19
+ remoteStart: RemoteStartConfig;
20
+ deviceInfo?: DeviceAgentDeviceInfo;
21
+ onRemoteSessionStarted?: (sessionId: string) => void;
22
+ createControlChannel?: (convexUrl: string) => DeviceControlChannel;
23
+ }
24
+
25
+ export function PickleballDeviceAgent(props: PickleballDeviceAgentProps) {
26
+ const host = useLivestreamHost();
27
+
28
+ // Giữ tham chiếu mới nhất để agent (tạo 1 lần) luôn gọi callback hiện hành
29
+ const propsRef = useRef(props);
30
+ propsRef.current = props;
31
+
32
+ useEffect(() => {
33
+ const agent = new DeviceAgent({
34
+ engine: host.engine,
35
+ bootstrapInput: host.bootstrapInput,
36
+ deviceMetrics: host.deviceMetrics,
37
+ transport: propsRef.current.transport,
38
+ remoteStart: propsRef.current.remoteStart,
39
+ deviceInfo: propsRef.current.deviceInfo,
40
+ createControlChannel: propsRef.current.createControlChannel,
41
+ onRemoteSessionStarted: (sessionId) =>
42
+ propsRef.current.onRemoteSessionStarted?.(sessionId),
43
+ });
44
+ agent.start();
45
+ return () => agent.stop();
46
+ }, [host]);
47
+
48
+ return null;
49
+ }
@@ -0,0 +1,82 @@
1
+ import { ConvexClient } from "convex/browser";
2
+ import { anyApi } from "convex/server";
3
+ import type { DeviceCommand, DeviceControlChannel } from "./agent";
4
+
5
+ /**
6
+ * Kênh điều khiển realtime mặc định: Convex websocket subscribe các function
7
+ * deviceControl (auth bằng connect-token nhận qua heartbeat — thiết bị không
8
+ * có account, pattern pairingCode). Chỉ mở khi dashboard yêu cầu "Kết nối",
9
+ * đóng ngay khi kênh kết thúc → không giữ websocket lúc idle.
10
+ */
11
+ export function createConvexControlChannel(convexUrl: string): DeviceControlChannel {
12
+ const client = new ConvexClient(convexUrl);
13
+ const deviceControl = anyApi.deviceControl;
14
+
15
+ function toCommands(value: unknown): DeviceCommand[] {
16
+ if (!Array.isArray(value)) return [];
17
+ const commands: DeviceCommand[] = [];
18
+ for (const item of value) {
19
+ if (item === null || typeof item !== "object") continue;
20
+ const record = item as Record<string, unknown>;
21
+ if (
22
+ typeof record.id !== "string" ||
23
+ (record.type !== "start_livestream" && record.type !== "stop_livestream") ||
24
+ typeof record.expiresAt !== "number"
25
+ ) {
26
+ continue;
27
+ }
28
+ const payload =
29
+ record.payload !== null &&
30
+ typeof record.payload === "object" &&
31
+ !Array.isArray(record.payload)
32
+ ? (record.payload as Record<string, unknown>)
33
+ : {};
34
+ commands.push({
35
+ id: record.id,
36
+ type: record.type,
37
+ payload: {
38
+ ...(typeof payload.title === "string" ? { title: payload.title } : {}),
39
+ ...(payload.visibility === "public" || payload.visibility === "private"
40
+ ? { visibility: payload.visibility }
41
+ : {}),
42
+ },
43
+ expiresAt: record.expiresAt,
44
+ });
45
+ }
46
+ return commands;
47
+ }
48
+
49
+ return {
50
+ async attach(controlSessionId, token) {
51
+ await client.mutation(deviceControl.deviceAttach, { controlSessionId, token });
52
+ },
53
+ onCommands(controlSessionId, token, callback) {
54
+ return client.onUpdate(
55
+ deviceControl.pendingCommands,
56
+ { controlSessionId, token },
57
+ (value) => callback(toCommands(value)),
58
+ );
59
+ },
60
+ onState(controlSessionId, token, callback) {
61
+ return client.onUpdate(
62
+ deviceControl.controlSessionState,
63
+ { controlSessionId, token },
64
+ (value) => {
65
+ const status =
66
+ value !== null &&
67
+ typeof value === "object" &&
68
+ typeof (value as Record<string, unknown>).status === "string"
69
+ ? ((value as Record<string, unknown>).status as string)
70
+ : "ended";
71
+ callback({ status });
72
+ },
73
+ );
74
+ },
75
+ async ack(input) {
76
+ await client.mutation(deviceControl.ackCommand, { ...input });
77
+ },
78
+ close() {
79
+ void client.close();
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,143 @@
1
+ import type { BootstrapInput } from "../contracts";
2
+ import type {
3
+ DeviceAgentDeviceInfo,
4
+ DeviceAgentTransport,
5
+ DeviceControlSessionGrant,
6
+ DeviceHeartbeatBody,
7
+ } from "./agent";
8
+ import {
9
+ ExpoSdkConfigurationError,
10
+ ExpoSdkTimeoutError,
11
+ InvalidResponseError,
12
+ NetworkUnavailableError,
13
+ PickleballExpoError,
14
+ errorFromCode,
15
+ } from "../errors";
16
+
17
+ type Fetch = typeof globalThis.fetch;
18
+ type HeadersInput = Record<string, string>;
19
+
20
+ export interface HttpDeviceAgentTransportOptions {
21
+ /** Base URL của proxy đối tác (cùng proxy với session provider). */
22
+ baseUrl: string;
23
+ fetch?: Fetch;
24
+ headers?: HeadersInput | (() => HeadersInput | Promise<HeadersInput>);
25
+ timeoutMs?: number;
26
+ paths?: Partial<{ register: string; heartbeat: string }>;
27
+ }
28
+
29
+ const defaultPaths = {
30
+ register: "/devices/register",
31
+ heartbeat: "/devices/heartbeat",
32
+ };
33
+
34
+ /** Transport HTTP cho DeviceAgent — mirror createHttpSessionProvider. */
35
+ export function createHttpDeviceAgentTransport(
36
+ options: HttpDeviceAgentTransportOptions,
37
+ ): DeviceAgentTransport {
38
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
39
+ if (baseUrl === "") {
40
+ throw new ExpoSdkConfigurationError("baseUrl must be a non-empty string");
41
+ }
42
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
43
+ if (typeof fetchImplementation !== "function") {
44
+ throw new ExpoSdkConfigurationError("A Fetch API implementation is required");
45
+ }
46
+ const paths = { ...defaultPaths, ...options.paths };
47
+
48
+ async function post(path: string, body: unknown): Promise<unknown> {
49
+ const controller = new AbortController();
50
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
51
+ try {
52
+ const extraHeaders =
53
+ typeof options.headers === "function"
54
+ ? await options.headers()
55
+ : (options.headers ?? {});
56
+ const response = await fetchImplementation(
57
+ `${baseUrl}/${path.replace(/^\/+/, "")}`,
58
+ {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json", ...extraHeaders },
61
+ body: JSON.stringify(body),
62
+ signal: controller.signal,
63
+ redirect: "error",
64
+ },
65
+ );
66
+ const envelope = await response.json().catch(() => {
67
+ throw new InvalidResponseError();
68
+ });
69
+ if (!response.ok) throw parseProxyError(envelope);
70
+ if (!isRecord(envelope) || !("data" in envelope)) {
71
+ throw new InvalidResponseError();
72
+ }
73
+ return envelope.data;
74
+ } catch (error) {
75
+ if (controller.signal.aborted) throw new ExpoSdkTimeoutError();
76
+ if (error instanceof PickleballExpoError) throw error;
77
+ if (error instanceof TypeError) {
78
+ throw new NetworkUnavailableError(undefined, { cause: error });
79
+ }
80
+ throw error;
81
+ } finally {
82
+ clearTimeout(timeout);
83
+ }
84
+ }
85
+
86
+ return {
87
+ async register(input: BootstrapInput & DeviceAgentDeviceInfo) {
88
+ const data = await post(paths.register, input);
89
+ if (
90
+ !isRecord(data) ||
91
+ typeof data.deviceId !== "string" ||
92
+ typeof data.heartbeatIntervalMs !== "number" ||
93
+ !Number.isFinite(data.heartbeatIntervalMs)
94
+ ) {
95
+ throw new InvalidResponseError();
96
+ }
97
+ return {
98
+ deviceId: data.deviceId,
99
+ heartbeatIntervalMs: data.heartbeatIntervalMs,
100
+ };
101
+ },
102
+ async heartbeat(input: BootstrapInput & DeviceHeartbeatBody) {
103
+ const data = await post(paths.heartbeat, input);
104
+ if (!isRecord(data)) throw new InvalidResponseError();
105
+ const grant = data.controlSession;
106
+ if (grant === null || grant === undefined) return { controlSession: null };
107
+ if (
108
+ !isRecord(grant) ||
109
+ typeof grant.id !== "string" ||
110
+ typeof grant.token !== "string" ||
111
+ typeof grant.convexUrl !== "string" ||
112
+ grant.convexUrl === ""
113
+ ) {
114
+ throw new InvalidResponseError();
115
+ }
116
+ return {
117
+ controlSession: {
118
+ id: grant.id,
119
+ token: grant.token,
120
+ convexUrl: grant.convexUrl,
121
+ } satisfies DeviceControlSessionGrant,
122
+ };
123
+ },
124
+ };
125
+ }
126
+
127
+ function parseProxyError(envelope: unknown) {
128
+ if (
129
+ isRecord(envelope) &&
130
+ isRecord(envelope.error) &&
131
+ typeof envelope.error.code === "string"
132
+ ) {
133
+ return errorFromCode(
134
+ envelope.error.code,
135
+ typeof envelope.error.message === "string" ? envelope.error.message : undefined,
136
+ );
137
+ }
138
+ return new InvalidResponseError();
139
+ }
140
+
141
+ function isRecord(value: unknown): value is Record<string, unknown> {
142
+ return typeof value === "object" && value !== null && !Array.isArray(value);
143
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./agent";
2
+ export * from "./http-transport";
3
+ export { createConvexControlChannel } from "./convex-control-channel";
4
+ export { PickleballDeviceAgent, type PickleballDeviceAgentProps } from "./component";
package/src/engine.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  SdkDisabledError,
19
19
  SessionConflictError,
20
20
  UnsupportedSdkError,
21
+ isSessionGoneError,
21
22
  normalizeError,
22
23
  } from "./errors";
23
24
  import { isSdkRemoteConfig, isSdkVersionSupported } from "./contracts";
@@ -240,7 +241,11 @@ export class LivestreamEngine {
240
241
  sessionId: null,
241
242
  previewTrack: null,
242
243
  isMicrophoneEnabled: true,
243
- isCameraFront: true,
244
+ // Camera SAU mặc định: thiết bị quay sân luôn dùng camera sau, bắt người
245
+ // dùng bấm đổi mỗi buổi là thừa. Phải khớp với mặc định của MỌI adapter
246
+ // (LiveKitRoomAdapter.isFront, module RTMP cameraPosition) — lệch một chỗ
247
+ // là nhãn UI và hình thực tế ngược nhau vĩnh viễn.
248
+ isCameraFront: false,
244
249
  captureOrientation: null,
245
250
  deviceMetrics: null,
246
251
  cameras: [],
@@ -747,10 +752,21 @@ export class LivestreamEngine {
747
752
  );
748
753
  }
749
754
  if (!this.dependencies.network.isOnline()) return;
750
- await this.dependencies.sessionProvider.end({
751
- sessionId: pending.sessionId,
752
- reason: pending.pendingEnd ?? "error",
753
- });
755
+ try {
756
+ await this.dependencies.sessionProvider.end({
757
+ sessionId: pending.sessionId,
758
+ reason: pending.pendingEnd ?? "error",
759
+ });
760
+ } catch (error) {
761
+ // CHỈ 404 mới xoá dấu: phiên đã biến mất hẳn (bị watchdog dọn, thuộc
762
+ // deployment khác, ID sai) nên thử lại bao nhiêu lần cũng vô ích, mà giữ
763
+ // dấu thì thiết bị bị khoá VĨNH VIỄN với thông báo "Đang có phiên phát
764
+ // khác" — box hết pin giữa trận là dính, chỉ gỡ được bằng cài lại app.
765
+ //
766
+ // Mất mạng hay 5xx thì KHÔNG xoá: phiên cũ có thể vẫn đang chạy thật, xoá
767
+ // dấu sẽ dẫn tới hai phiên live song song.
768
+ if (!isSessionGoneError(error)) throw error;
769
+ }
754
770
  await this.dependencies.storage.removeItem(PENDING_END_KEY);
755
771
  this.pendingReplayError = undefined;
756
772
  }
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
+ }
@@ -80,7 +80,7 @@ export function createHttpSessionProvider(
80
80
  const envelope = await response.json().catch(() => {
81
81
  throw new InvalidResponseError();
82
82
  });
83
- if (!response.ok) throw parseProxyError(envelope);
83
+ if (!response.ok) throw parseProxyError(envelope, response.status);
84
84
  if (!isRecord(envelope) || !("data" in envelope)) {
85
85
  throw new InvalidResponseError();
86
86
  }
@@ -168,22 +168,25 @@ function joinUrl(base: string, path: string) {
168
168
  return `${base}/${path.replace(/^\/+/, "")}`;
169
169
  }
170
170
 
171
- function parseProxyError(envelope: unknown) {
172
- if (
171
+ function parseProxyError(envelope: unknown, httpStatus: number) {
172
+ const error =
173
173
  isRecord(envelope) &&
174
174
  isRecord(envelope.error) &&
175
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();
176
+ ? errorFromCode(
177
+ envelope.error.code,
178
+ typeof envelope.error.message === "string"
179
+ ? envelope.error.message
180
+ : undefined,
181
+ )
182
+ : new InvalidResponseError();
183
+ // Mã lỗi trong envelope không phân biệt được "phiên đã biến mất" (404) với
184
+ // "server đang trục trặc" (5xx) — cả hai đều là UNKNOWN. Giữ lại mã HTTP để
185
+ // engine quyết được: 404 thì xoá dấu pending-end, 5xx thì giữ và thử lại.
186
+ return Object.assign(error, { httpStatus });
185
187
  }
186
188
 
189
+
187
190
  function isRecord(value: unknown): value is Record<string, unknown> {
188
191
  return typeof value === "object" && value !== null && !Array.isArray(value);
189
192
  }
@@ -219,7 +222,10 @@ function isSessionGrant(value: unknown): value is SdkSessionGrant {
219
222
  typeof value.sessionId === "string" &&
220
223
  value.sessionId.length > 0 &&
221
224
  typeof value.serverUrl === "string" &&
222
- /^wss:\/\//.test(value.serverUrl) &&
225
+ // wss:// cho mode LiveKit, rtmp(s):// cho mode device-rtmp. Trước đây chỉ
226
+ // chấp nhận wss:// nên grant RTMP bị coi là "phản hồi máy chủ không hợp lệ".
227
+ // Vẫn chặn ws:// trần để không rơi xuống WebSocket không mã hoá.
228
+ /^(wss|rtmps?):\/\//.test(value.serverUrl) &&
223
229
  typeof value.participantToken === "string" &&
224
230
  value.participantToken.length > 0 &&
225
231
  (value.playbackUrl === null || typeof value.playbackUrl === "string") &&
package/src/index.ts CHANGED
@@ -1,16 +1,40 @@
1
+ /**
2
+ * Entry gọn cho tích hợp livestream cơ bản. Các tính năng khác nằm ở subpath:
3
+ * @pickleball/expo-sdk/match — scoreboard + persistence
4
+ * @pickleball/expo-sdk/match/voice — đọc điểm bằng loa
5
+ * @pickleball/expo-sdk/match/remote — remote BLE/HID
6
+ * @pickleball/expo-sdk/device-agent — điều khiển từ dashboard
7
+ * @pickleball/expo-sdk/advanced — engine internals, DI, mock/test
8
+ * Metro không tree-shake nên chỉ import subpath nào app thật sự dùng.
9
+ */
1
10
  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
11
  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";
12
+ export {
13
+ PickleballLiveProvider,
14
+ useLivestream,
15
+ defaultLivestreamTheme,
16
+ type PickleballLiveProviderProps,
17
+ type LivestreamContextValue,
18
+ type LivestreamTheme,
19
+ } from "./provider";
20
+ export { LivestreamScreen, type LivestreamScreenProps } from "./livestream-screen";
21
+ export {
22
+ createHttpSessionProvider,
23
+ type HttpSessionProviderOptions,
24
+ type HttpSessionProviderPaths,
25
+ } from "./http-session-provider";
26
+ // Partner dùng để persist token/cấu hình (expo-sqlite localStorage)
27
+ export { createDefaultStorage } from "./native-runtime";
28
+ // Type-only: xuất hiện trong LivestreamContextValue/ProviderProps
29
+ export type {
30
+ CameraDeviceInfo,
31
+ CaptureOrientation,
32
+ DeviceMetricsSample,
33
+ LivestreamSnapshot,
34
+ RoomAdapter,
35
+ SdkNetwork,
36
+ SdkRuntime,
37
+ SdkStorage,
38
+ TelemetrySink,
39
+ } from "./engine";
40
+ export { PICKLEBALL_EXPO_SDK_VERSION } from "./version";
@@ -93,7 +93,8 @@ export class LiveKitRoomAdapter implements RoomAdapter {
93
93
  private videoTrack?: LocalTrackLike;
94
94
  private audioTrack?: LocalTrackLike;
95
95
  private preview?: LocalPreviewReference;
96
- private isFront = true;
96
+ // Khớp engine (isCameraFront: false) — xem ghi chú ở engine.ts.
97
+ private isFront = false;
97
98
  private orientationOverride: "portrait" | "landscape" | null = null;
98
99
  private deviceOverride: string | null = null;
99
100
  private published = false;
@@ -147,6 +148,15 @@ export class LiveKitRoomAdapter implements RoomAdapter {
147
148
 
148
149
  async connect(input: RoomConnection) {
149
150
  this.ensureNotDisposed();
151
+ // Chặn lệch mode (đối xứng với guard trong RtmpRoomAdapter): nếu app trên
152
+ // server đã chuyển sang device-rtmp thì grant trả rtmp:// — LiveKit không
153
+ // hiểu URL đó và sẽ hỏng câm lặng nếu không báo ở đây.
154
+ if (!/^wss?:\/\//i.test(input.serverUrl)) {
155
+ throw new Error(
156
+ `Mode LiveKit cần endpoint wss:// nhưng phiên trả về "${input.serverUrl}". ` +
157
+ "App trên server đang đặt deliveryMode khác — đổi mode trong app cho khớp.",
158
+ );
159
+ }
150
160
  if (!this.preview) throw new Error("Preview must be prepared before connecting");
151
161
  if (this.room.state !== "disconnected") await this.disconnect(false);
152
162
  await this.ensurePreparedTracks();
@@ -175,7 +185,7 @@ export class LiveKitRoomAdapter implements RoomAdapter {
175
185
  if (enabled) {
176
186
  const publication = await this.room.localParticipant.publishTrack(
177
187
  this.videoTrack,
178
- { simulcast: false, source: this.bindings.cameraSource },
188
+ this.videoPublishOptions(),
179
189
  );
180
190
  await this.room.localParticipant.publishTrack(this.audioTrack);
181
191
  this.preview.trackRef = {
@@ -210,6 +220,33 @@ export class LiveKitRoomAdapter implements RoomAdapter {
210
220
  return { width: long, height: short, frameRate: 30 };
211
221
  }
212
222
 
223
+ /**
224
+ * Tùy chọn publish video. Mặc định của livekit-client (VP8, không encoding,
225
+ * không degradation preference) khiến uplink yếu bóp bitrate xuống ~600kbps
226
+ * mà VẪN giữ 1080 @ ~10fps → khối bể nát, tệ hơn 720p sạch.
227
+ *
228
+ * - videoCodec H.264: HW-encode trên Android, hiệu quả hơn VP8 ở bitrate thấp,
229
+ * trùng preset egress (H264_*) nên bỏ được 1 lần transcode phía egress.
230
+ * - simulcast tắt: viewer chỉ xem HLS do egress xuất; egress subscribe đúng
231
+ * 1 lớp đã publish, nên simulcast chỉ thêm tải/bất ổn encoder (H.264 simulcast
232
+ * kém tin cậy trên Android) mà không lợi gì cho người xem ở kiến trúc này.
233
+ * - maintain-framerate: pickleball chuyển động nhanh; khi uplink không gánh nổi
234
+ * mức đích thì HẠ ĐỘ PHÂN GIẢI (giữ nét-trên-điểm-ảnh) thay vì đóng băng fps.
235
+ * - maxBitrate: trần theo mức chất lượng; BWE vẫn tự hạ dưới trần khi mạng yếu.
236
+ */
237
+ private videoPublishOptions() {
238
+ return {
239
+ source: this.bindings.cameraSource,
240
+ simulcast: false,
241
+ videoCodec: "h264",
242
+ degradationPreference: "maintain-framerate",
243
+ videoEncoding: {
244
+ maxBitrate: this.quality === 1080 ? 4_500_000 : 2_500_000,
245
+ maxFramerate: 30,
246
+ },
247
+ };
248
+ }
249
+
213
250
  async setCaptureOrientation(orientation: "portrait" | "landscape") {
214
251
  this.ensureNotDisposed();
215
252
  // Chỉ ghi nhận lựa chọn (phục vụ hint UI/telemetry); capture format không
@@ -8,6 +8,7 @@ import {
8
8
  type ViewStyle,
9
9
  } from "react-native";
10
10
  import { useSafeAreaInsets } from "react-native-safe-area-context";
11
+ import { sessionShortCode } from "@pickleball/shared";
11
12
  import type { LivestreamState, StartLivestreamInput } from "./contracts";
12
13
  import type { ExpoSdkErrorCode } from "./errors";
13
14
  import type { LocalPreviewReference } from "./livekit-room-adapter";
@@ -447,6 +448,16 @@ export function LivestreamScreen(props: LivestreamScreenProps) {
447
448
  {formatElapsed(state.elapsedMs)}
448
449
  </Text>
449
450
  ) : null}
451
+ {/* Mã ngắn trùng với mã trên dashboard — 8 sân chạy cùng lúc thì
452
+ cần thứ đối chiếu được, tiêu đề không đủ phân biệt. */}
453
+ {state.sessionId ? (
454
+ <Text
455
+ selectable
456
+ style={[{ color: theme.textSecondary, fontSize: 12, letterSpacing: 1 }, bodyFont]}
457
+ >
458
+ {sessionShortCode(state.sessionId)}
459
+ </Text>
460
+ ) : null}
450
461
  {inLive && state.deviceMetrics ? (
451
462
  <Text
452
463
  selectable
@@ -953,17 +964,28 @@ function AspectBox({
953
964
  }
954
965
 
955
966
  function DefaultPreview({ state }: { state: RenderState }) {
956
- const preview = state.previewTrack as LocalPreviewReference | null;
967
+ const preview = state.previewTrack as
968
+ | LocalPreviewReference
969
+ | { kind?: string }
970
+ | null;
957
971
  if (!preview) return <View style={{ flex: 1, backgroundColor: state.theme.background }} />;
972
+ // Mode device-rtmp: khung hình đi thẳng trong native, JS không có track nào để
973
+ // bind. Render view native của HaishinKit; thiếu nhánh này thì màn TỐI ĐEN dù
974
+ // camera đã mở và đang encode bình thường.
975
+ if ((preview as { kind?: string }).kind === "rtmp") {
976
+ const rtmp = require("@pickleball/rtmp-native");
977
+ return <rtmp.PickleballRtmpPreviewView style={{ flex: 1 }} />;
978
+ }
958
979
  const liveKit = require("@livekit/react-native");
980
+ const liveKitPreview = preview as LocalPreviewReference;
959
981
  // restartTrack thay mediaStreamTrack bên trong cùng một track object nên
960
982
  // React không thấy prop đổi; remount view theo camera đang chọn để render
961
983
  // đúng stream mới sau khi lật camera / chọn ống kính.
962
984
  const remountKey = `${state.selectedCameraId ?? "auto"}:${state.isCameraFront ? "front" : "back"}`;
963
- return preview.trackRef ? (
964
- <liveKit.VideoTrack key={remountKey} trackRef={preview.trackRef} mirror={state.isCameraFront} objectFit="cover" style={{ flex: 1 }} />
985
+ return liveKitPreview.trackRef ? (
986
+ <liveKit.VideoTrack key={remountKey} trackRef={liveKitPreview.trackRef} mirror={state.isCameraFront} objectFit="cover" style={{ flex: 1 }} />
965
987
  ) : (
966
- <liveKit.VideoView key={remountKey} videoTrack={preview.videoTrack} mirror={state.isCameraFront} objectFit="cover" style={{ flex: 1 }} />
988
+ <liveKit.VideoView key={remountKey} videoTrack={liveKitPreview.videoTrack} mirror={state.isCameraFront} objectFit="cover" style={{ flex: 1 }} />
967
989
  );
968
990
  }
969
991
 
@@ -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
  }