@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
@@ -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(),
@@ -1,5 +1,6 @@
1
1
  import type { EngineDependencies, SdkNetwork, SdkRuntime, SdkStorage, TelemetrySink } from "./engine";
2
2
  import { LiveKitRoomAdapter, type NativeLiveKitBindings } from "./livekit-room-adapter";
3
+ import { RtmpRoomAdapter, type NativeRtmpBindings } from "./rtmp-room-adapter";
3
4
  import { UnsupportedRuntimeError } from "./errors";
4
5
 
5
6
  declare const require: (name: string) => any;
@@ -89,11 +90,29 @@ export function createDefaultRuntime(): SdkRuntime {
89
90
  }
90
91
 
91
92
  export function createDefaultStorage(): SdkStorage {
92
- require("expo-sqlite/localStorage/install");
93
+ // Cài localStorage của expo-sqlite ở LẦN DÙNG ĐẦU, không phải lúc gọi factory:
94
+ // app thường tạo storage ở module scope (root layout), và static-render web
95
+ // của expo-router nạp module đó trong Node nơi expo-sqlite không resolve được.
96
+ // Không có lời gọi get/set/remove nào khi render tĩnh nên require không chạy.
97
+ let installed = false;
98
+ const install = () => {
99
+ if (installed) return;
100
+ require("expo-sqlite/localStorage/install");
101
+ installed = true;
102
+ };
93
103
  return {
94
- getItem: async (key) => globalThis.localStorage.getItem(key),
95
- setItem: async (key, value) => globalThis.localStorage.setItem(key, value),
96
- removeItem: async (key) => globalThis.localStorage.removeItem(key),
104
+ getItem: async (key) => {
105
+ install();
106
+ return globalThis.localStorage.getItem(key);
107
+ },
108
+ setItem: async (key, value) => {
109
+ install();
110
+ globalThis.localStorage.setItem(key, value);
111
+ },
112
+ removeItem: async (key) => {
113
+ install();
114
+ globalThis.localStorage.removeItem(key);
115
+ },
97
116
  };
98
117
  }
99
118
 
@@ -159,6 +178,37 @@ export function createDefaultRoomFactory(): EngineDependencies["roomFactory"] {
159
178
  return () => new LiveKitRoomAdapter(createNativeLiveKitBindings());
160
179
  }
161
180
 
181
+ /**
182
+ * Bindings cho mode device-rtmp. `require` động giống LiveKit ở trên: app nào
183
+ * không cài @pickleball/rtmp-native thì chỉ lỗi khi thật sự chọn mode này,
184
+ * không gãy lúc import SDK.
185
+ */
186
+ export function createNativeRtmpBindings(): NativeRtmpBindings {
187
+ const native = require("@pickleball/rtmp-native").default;
188
+ return {
189
+ prepare: (options) => native.prepare(options),
190
+ isLandscape() {
191
+ const { width, height } = require("react-native").Dimensions.get("window");
192
+ return width > height;
193
+ },
194
+ connect: (options) => native.connect(options),
195
+ setPublishing: (enabled) => native.setPublishing(enabled),
196
+ disconnect: () => native.disconnect(),
197
+ switchCamera: () => native.switchCamera(),
198
+ listCameras: () => native.listCameras(),
199
+ selectCamera: (deviceId, facing) => native.selectCamera(deviceId, facing),
200
+ setMicrophoneEnabled: (enabled) => native.setMicrophoneEnabled(enabled),
201
+ setVideoBitrate: (bps) => native.setVideoBitrate(bps),
202
+ dispose: () => native.dispose(),
203
+ addStatusListener: (listener) => native.addListener("onStatusChanged", listener),
204
+ };
205
+ }
206
+
207
+ /** roomFactory cho mode device-rtmp (thiết bị đẩy RTMP, không dùng LiveKit). */
208
+ export function createRtmpRoomFactory(): EngineDependencies["roomFactory"] {
209
+ return () => new RtmpRoomAdapter(createNativeRtmpBindings());
210
+ }
211
+
162
212
  /**
163
213
  * Thu chỉ số thiết bị cho heartbeat. Mỗi nguồn là optional — thiếu package
164
214
  * (expo-battery/expo-device/expo-file-system) thì bỏ qua trường đó thay vì lỗi.
package/src/plugin.ts CHANGED
@@ -16,19 +16,27 @@ export interface PickleballLivePluginProps {
16
16
  microphonePermission?: string;
17
17
  bluetoothPermission?: string;
18
18
  consumeHidVolumeKeys?: boolean;
19
+ /**
20
+ * Tắt phần cấu hình native của module không dùng để app gọn hơn:
21
+ * - remote: false → bỏ permission Bluetooth (Android + iOS usage string) và
22
+ * không patch MainActivity nhận phím HID. Dùng khi app không import
23
+ * "@pickleball/expo-sdk/match/remote".
24
+ * - voice: false → hiện chưa gate gì trong plugin (mic thuộc livestream);
25
+ * nhận trước để tương thích về sau. Muốn loại hẳn native khỏi binary, thêm
26
+ * expo.autolinking.exclude trong package.json của app (xem README).
27
+ */
28
+ modules?: {
29
+ remote?: boolean;
30
+ voice?: boolean;
31
+ };
19
32
  liveKit?: {
20
33
  android?: { audioType?: string; enableScreenShareService?: boolean };
21
34
  ios?: { enableMultitaskingCameraAccess?: boolean };
22
35
  };
23
36
  }
24
37
 
25
- const ANDROID_PERMISSIONS = [
38
+ const CORE_ANDROID_PERMISSIONS = [
26
39
  "android.permission.ACCESS_NETWORK_STATE",
27
- "android.permission.BLUETOOTH",
28
- "android.permission.BLUETOOTH_ADMIN",
29
- "android.permission.BLUETOOTH_SCAN",
30
- "android.permission.BLUETOOTH_CONNECT",
31
- "android.permission.ACCESS_FINE_LOCATION",
32
40
  "android.permission.CAMERA",
33
41
  "android.permission.INTERNET",
34
42
  "android.permission.MODIFY_AUDIO_SETTINGS",
@@ -36,6 +44,15 @@ const ANDROID_PERMISSIONS = [
36
44
  "android.permission.WAKE_LOCK",
37
45
  ] as const;
38
46
 
47
+ // Chỉ cần cho remote BLE/HID (@pickleball/expo-sdk/match/remote)
48
+ const REMOTE_ANDROID_PERMISSIONS = [
49
+ "android.permission.BLUETOOTH",
50
+ "android.permission.BLUETOOTH_ADMIN",
51
+ "android.permission.BLUETOOTH_SCAN",
52
+ "android.permission.BLUETOOTH_CONNECT",
53
+ "android.permission.ACCESS_FINE_LOCATION",
54
+ ] as const;
55
+
39
56
  export function injectHidVolumeMainActivity(source: string): string {
40
57
  if (source.includes("PickleballRemoteHidDispatcher.dispatch(event)")) return source;
41
58
  if (!/class\s+MainActivity\s*:\s*ReactActivity\(\)\s*\{/.test(source)) {
@@ -76,6 +93,10 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
76
93
  const bluetoothPermission =
77
94
  props?.bluetoothPermission ??
78
95
  "Cho phép $(PRODUCT_NAME) dùng Bluetooth để nhận thao tác từ nút điều khiển trên sân.";
96
+ const remoteEnabled = props?.modules?.remote !== false;
97
+ const androidPermissions = remoteEnabled
98
+ ? [...CORE_ANDROID_PERMISSIONS, ...REMOTE_ANDROID_PERMISSIONS]
99
+ : [...CORE_ANDROID_PERMISSIONS];
79
100
 
80
101
  config = withPlugins(config, [
81
102
  ["@livekit/react-native-expo-plugin", props?.liveKit],
@@ -84,12 +105,12 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
84
105
  config = withInfoPlist(config, (mod) => {
85
106
  mod.modResults.NSCameraUsageDescription = cameraPermission;
86
107
  mod.modResults.NSMicrophoneUsageDescription = microphonePermission;
87
- mod.modResults.NSBluetoothAlwaysUsageDescription = bluetoothPermission;
108
+ if (remoteEnabled) {
109
+ mod.modResults.NSBluetoothAlwaysUsageDescription = bluetoothPermission;
110
+ }
88
111
  return mod;
89
112
  });
90
- config = AndroidConfig.Permissions.withPermissions(config, [
91
- ...ANDROID_PERMISSIONS,
92
- ]);
113
+ config = AndroidConfig.Permissions.withPermissions(config, androidPermissions);
93
114
  config = withAndroidManifest(config, (mod) => {
94
115
  const manifest = mod.modResults.manifest;
95
116
  const existing = manifest["uses-permission"] ?? [];
@@ -106,7 +127,7 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
106
127
  const name = entry.$?.["android:name"];
107
128
  if (name && !byName.has(name)) byName.set(name, entry);
108
129
  }
109
- for (const permission of ANDROID_PERMISSIONS) {
130
+ for (const permission of androidPermissions) {
110
131
  const entry = byName.get(permission) ?? { $: { "android:name": permission } };
111
132
  entry.$ = {
112
133
  "android:name": permission,
@@ -114,17 +135,17 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
114
135
  };
115
136
  byName.set(permission, entry);
116
137
  }
117
- const requiredNames = new Set<string>(ANDROID_PERMISSIONS);
138
+ const requiredNames = new Set<string>(androidPermissions);
118
139
  manifest["uses-permission"] = [
119
140
  ...existing.filter((entry) => {
120
141
  const name = entry.$?.["android:name"];
121
142
  return !name || !requiredNames.has(name);
122
143
  }),
123
- ...ANDROID_PERMISSIONS.map((name) => byName.get(name)!),
144
+ ...androidPermissions.map((name) => byName.get(name)!),
124
145
  ];
125
146
  return mod;
126
147
  });
127
- if (props?.consumeHidVolumeKeys !== false) {
148
+ if (remoteEnabled && props?.consumeHidVolumeKeys !== false) {
128
149
  config = withDangerousMod(config, ["android", async (mod) => {
129
150
  const root = join(mod.modRequest.platformProjectRoot, "app", "src", "main", "java");
130
151
  const path = await findMainActivity(root);
package/src/provider.tsx CHANGED
@@ -75,6 +75,28 @@ export interface PickleballLiveProviderProps {
75
75
  onError?: (error: PickleballExpoError) => void;
76
76
  }
77
77
 
78
+ /**
79
+ * Context nội bộ cho các module opt-in (VD PickleballDeviceAgent) cần truy cập
80
+ * engine trực tiếp — export qua "./advanced", không thuộc API tích hợp cơ bản.
81
+ */
82
+ export interface LivestreamHostContextValue {
83
+ engine: LivestreamEngine;
84
+ bootstrapInput: BootstrapInput;
85
+ deviceMetrics: NonNullable<EngineDependencies["deviceMetrics"]>;
86
+ }
87
+
88
+ const LivestreamHostContext = React.createContext<LivestreamHostContextValue | null>(null);
89
+
90
+ export function useLivestreamHost(): LivestreamHostContextValue {
91
+ const value = React.use(LivestreamHostContext);
92
+ if (!value) {
93
+ throw new ExpoSdkConfigurationError(
94
+ "useLivestreamHost must be used inside PickleballLiveProvider",
95
+ );
96
+ }
97
+ return value;
98
+ }
99
+
78
100
  export interface LivestreamContextValue extends LivestreamSnapshot {
79
101
  locale: "vi" | "en";
80
102
  theme: LivestreamTheme;
@@ -99,8 +121,10 @@ const LivestreamContext = React.createContext<LivestreamContextValue | null>(nul
99
121
 
100
122
  export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
101
123
  const engineRef = useRef<LivestreamEngine | null>(null);
124
+ const hostRef = useRef<LivestreamHostContextValue | null>(null);
102
125
  const effectGenerationRef = useRef(0);
103
126
  if (!engineRef.current) {
127
+ const deviceMetrics = props.deviceMetrics ?? createDefaultDeviceMetrics();
104
128
  const dependencies: EngineDependencies = {
105
129
  sessionProvider: props.sessionProvider,
106
130
  bootstrapInput: props.bootstrapInput,
@@ -108,10 +132,15 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
108
132
  storage: props.storage ?? createDefaultStorage(),
109
133
  network: props.network ?? createDefaultNetwork(),
110
134
  telemetry: props.telemetry ?? createDefaultTelemetry(),
111
- deviceMetrics: props.deviceMetrics ?? createDefaultDeviceMetrics(),
135
+ deviceMetrics,
112
136
  roomFactory: props.roomFactory ?? createDefaultRoomFactory(),
113
137
  };
114
138
  engineRef.current = new LivestreamEngine(dependencies);
139
+ hostRef.current = {
140
+ engine: engineRef.current,
141
+ bootstrapInput: props.bootstrapInput,
142
+ deviceMetrics,
143
+ };
115
144
  }
116
145
  const engine = engineRef.current;
117
146
  const snapshot = useSyncExternalStore(
@@ -159,9 +188,11 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
159
188
  selectCamera: (deviceId) => engine.selectCamera(deviceId),
160
189
  };
161
190
  return (
162
- <LivestreamContext.Provider value={value}>
163
- {props.children}
164
- </LivestreamContext.Provider>
191
+ <LivestreamHostContext.Provider value={hostRef.current}>
192
+ <LivestreamContext.Provider value={value}>
193
+ {props.children}
194
+ </LivestreamContext.Provider>
195
+ </LivestreamHostContext.Provider>
165
196
  );
166
197
  }
167
198
 
@@ -0,0 +1,196 @@
1
+ import type {
2
+ CameraDeviceInfo,
3
+ CaptureOrientation,
4
+ RoomAdapter,
5
+ RoomConnection,
6
+ RoomEvent,
7
+ } from "./engine";
8
+
9
+ /**
10
+ * Trạng thái từ tầng RTMP native (trùng NativeRtmpStatus của @pickleball/rtmp-native).
11
+ */
12
+ export type RtmpStatus =
13
+ | { readonly status: "idle" | "connecting" | "connected" | "publishing" }
14
+ | { readonly status: "disconnected"; readonly reason?: string }
15
+ | { readonly status: "error"; readonly code: string; readonly message: string };
16
+
17
+ /**
18
+ * Dấu hiệu để UI biết render nguồn nào. LiveKit trả về một track object; RTMP
19
+ * không có "track" ở JS (khung hình đi thẳng trong native), nên trả marker này
20
+ * — nếu trả undefined thì UI tưởng chưa có preview và vẽ màn TỐI ĐEN.
21
+ */
22
+ export interface RtmpPreviewMarker {
23
+ readonly kind: "rtmp";
24
+ }
25
+
26
+ /** Bề mặt native mà adapter cần — tiêm vào để test được không cần thiết bị thật. */
27
+ export interface NativeRtmpBindings {
28
+ prepare(options: { quality: 720 | 1080; landscape: boolean }): Promise<void>;
29
+ /** Hướng cầm máy hiện tại — cùng nguồn mà LiveKitRoomAdapter dùng. */
30
+ isLandscape?(): boolean;
31
+ connect(options: {
32
+ url: string;
33
+ videoBitrate: number;
34
+ publish: boolean;
35
+ }): Promise<void>;
36
+ setPublishing(enabled: boolean): Promise<void>;
37
+ disconnect(): Promise<void>;
38
+ switchCamera(): Promise<void>;
39
+ listCameras(): Array<{ deviceId: string; label: string; facing: string }>;
40
+ selectCamera(deviceId: string, facing: string): Promise<void>;
41
+ setMicrophoneEnabled(enabled: boolean): Promise<void>;
42
+ setVideoBitrate(bitsPerSecond: number): Promise<void>;
43
+ dispose(): Promise<void>;
44
+ addStatusListener(listener: (event: RtmpStatus) => void): { remove(): void };
45
+ }
46
+
47
+ /**
48
+ * Bitrate theo mức chất lượng — nằm trong khoảng TikTok khuyến nghị cho live
49
+ * (720p 3–6 Mbps, 1080p 6–9 Mbps). Thấp hơn khoảng này thì thể thao chuyển động
50
+ * nhanh như pickleball bị bể khối rất rõ.
51
+ */
52
+ const VIDEO_BITRATE: Record<720 | 1080, number> = {
53
+ 720: 3_500_000,
54
+ 1080: 6_000_000,
55
+ };
56
+
57
+ /**
58
+ * Adapter cho mode deliveryMode="device-rtmp".
59
+ *
60
+ * Thiết bị mã hoá H.264 bằng phần cứng MỘT lần rồi đẩy thẳng RTMP; server chỉ
61
+ * remux sang HLS. Không có phòng LiveKit, không egress → $0 transcode.
62
+ *
63
+ * Khác LiveKitRoomAdapter ở một điểm quan trọng: RTMP **không có kênh dữ liệu**,
64
+ * nên adapter này không bao giờ phát RoomEvent "command". Lệnh điều khiển từ
65
+ * dashboard đi đường khác (deviceControl trên Convex) — xem device-agent.
66
+ */
67
+ export class RtmpRoomAdapter implements RoomAdapter {
68
+ private readonly listeners = new Set<(event: RoomEvent) => void>();
69
+ private nativeSubscription?: { remove(): void };
70
+ private quality: 720 | 1080 = 1080;
71
+ private connected = false;
72
+
73
+ constructor(private readonly bindings: NativeRtmpBindings) {}
74
+
75
+ async preparePreview(quality: 720 | 1080): Promise<RtmpPreviewMarker> {
76
+ this.quality = quality;
77
+ // Mặc định ngang khi binding không cài: sân pickleball quay ngang là chính,
78
+ // và đó cũng là kích thước capture mặc định của HaishinKit.
79
+ await this.bindings.prepare({
80
+ quality,
81
+ landscape: this.bindings.isLandscape?.() ?? true,
82
+ });
83
+ // Khung hình do PickleballRtmpPreviewView vẽ ở tầng native; JS chỉ cần biết
84
+ // "đã có preview và là nguồn RTMP" để chọn đúng component hiển thị.
85
+ return { kind: "rtmp" };
86
+ }
87
+
88
+ async connect(input: RoomConnection) {
89
+ // Chặn lệch mode: nếu app chọn adapter RTMP nhưng phiên phía server lại là
90
+ // LiveKit thì serverUrl sẽ là wss://. Không kiểm ở đây thì HaishinKit nhận
91
+ // URL vô nghĩa và hỏng câm lặng, rất khó truy ngược.
92
+ if (!/^rtmps?:\/\//i.test(input.serverUrl)) {
93
+ throw new Error(
94
+ `Mode device-rtmp cần endpoint rtmp:// nhưng phiên trả về "${input.serverUrl}". ` +
95
+ "Kiểm tra deliveryMode của app trên server (partnerApps.deliveryMode).",
96
+ );
97
+ }
98
+ // Backend cấp rtmpsUrl kết thúc bằng "/" và rtmpsStreamKey riêng; ghép lại
99
+ // thành URL publish đầy đủ. Stream key đóng vai trò bearer token của phiên.
100
+ const url = `${input.serverUrl}${input.participantToken}`;
101
+ await this.bindings.setMicrophoneEnabled(input.microphoneEnabled);
102
+ await this.bindings.connect({
103
+ url,
104
+ videoBitrate: VIDEO_BITRATE[this.quality],
105
+ // publish !== false → mặc định phát ngay; false = vào standby.
106
+ publish: input.publish !== false,
107
+ });
108
+ this.connected = true;
109
+ }
110
+
111
+ async setPublishing(enabled: boolean) {
112
+ await this.bindings.setPublishing(enabled);
113
+ }
114
+
115
+ async disconnect(_stopTracks?: boolean) {
116
+ if (!this.connected) return;
117
+ this.connected = false;
118
+ await this.bindings.disconnect();
119
+ }
120
+
121
+ async switchCamera() {
122
+ await this.bindings.switchCamera();
123
+ }
124
+
125
+ /** Ống kính vật lý (0.5x siêu rộng · 1x · tele) — không có thì UI kẹt ở mặc định. */
126
+ async listCameras(): Promise<CameraDeviceInfo[]> {
127
+ return this.bindings.listCameras().map((camera) => ({
128
+ deviceId: camera.deviceId,
129
+ label: camera.label,
130
+ facing:
131
+ camera.facing === "front" || camera.facing === "environment"
132
+ ? camera.facing
133
+ : "unknown",
134
+ }));
135
+ }
136
+
137
+ async selectCamera(deviceId: string, facing: CameraDeviceInfo["facing"]) {
138
+ await this.bindings.selectCamera(deviceId, facing);
139
+ }
140
+
141
+ async setMicrophoneEnabled(enabled: boolean) {
142
+ await this.bindings.setMicrophoneEnabled(enabled);
143
+ }
144
+
145
+ /** Hạ chất lượng khi mạng yếu — thay cho BWE mà WebRTC cho sẵn. */
146
+ async setVideoBitrate(bitsPerSecond: number) {
147
+ await this.bindings.setVideoBitrate(bitsPerSecond);
148
+ }
149
+
150
+ async setCaptureOrientation(_orientation: CaptureOrientation) {
151
+ // Khung hình do camera quyết định; không đổi giữa luồng RTMP vì sẽ buộc
152
+ // khởi động lại encoder và làm thủng segment HLS.
153
+ }
154
+
155
+ subscribe(listener: (event: RoomEvent) => void) {
156
+ this.listeners.add(listener);
157
+ if (!this.nativeSubscription) {
158
+ this.nativeSubscription = this.bindings.addStatusListener((event) => {
159
+ const mapped = mapStatus(event);
160
+ if (mapped) this.emit(mapped);
161
+ });
162
+ }
163
+ return () => {
164
+ this.listeners.delete(listener);
165
+ };
166
+ }
167
+
168
+ async dispose() {
169
+ this.nativeSubscription?.remove();
170
+ this.nativeSubscription = undefined;
171
+ this.listeners.clear();
172
+ this.connected = false;
173
+ await this.bindings.dispose();
174
+ }
175
+
176
+ private emit(event: RoomEvent) {
177
+ for (const listener of this.listeners) listener(event);
178
+ }
179
+ }
180
+
181
+ /** Trạng thái native → RoomEvent của engine. `null` = không cần báo lên. */
182
+ export function mapStatus(event: RtmpStatus): RoomEvent | null {
183
+ switch (event.status) {
184
+ case "error":
185
+ return { type: "media-error", error: new Error(event.message) };
186
+ case "disconnected":
187
+ return { type: "disconnected", reason: event.reason };
188
+ case "connecting":
189
+ return { type: "reconnecting" };
190
+ case "publishing":
191
+ return { type: "reconnected" };
192
+ // "idle"/"connected" là bước trung gian, engine không cần biết.
193
+ default:
194
+ return null;
195
+ }
196
+ }
@@ -1,17 +0,0 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
7
-
8
- // package.json
9
- var version = "0.1.1";
10
-
11
- // src/version.ts
12
- var PICKLEBALL_EXPO_SDK_VERSION = version;
13
-
14
- export {
15
- __require,
16
- PICKLEBALL_EXPO_SDK_VERSION
17
- };