@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.
- package/CHANGELOG.md +24 -0
- package/README.md +69 -0
- package/dist/advanced-BXDrDl1h.d.ts +316 -0
- package/dist/advanced-BdAa2Erv.d.cts +316 -0
- package/dist/advanced.cjs +35 -0
- package/dist/advanced.d.cts +3 -0
- package/dist/advanced.d.ts +3 -0
- package/dist/advanced.js +35 -0
- package/dist/chunk-2JHULJZ7.js +265 -0
- package/dist/chunk-6JOMESNT.js +9 -0
- package/dist/chunk-7OX2DSKI.cjs +33 -0
- package/dist/chunk-DHDX3ZOO.js +800 -0
- package/dist/chunk-FODE2W2H.js +1953 -0
- package/dist/chunk-KFQGP6VL.js +33 -0
- package/dist/chunk-LCWGNGEN.cjs +265 -0
- package/dist/chunk-TBPR2QND.cjs +1953 -0
- package/dist/chunk-TRLASNPQ.cjs +9 -0
- package/dist/chunk-YGSQ25UA.cjs +800 -0
- package/dist/device-agent/index.cjs +498 -0
- package/dist/device-agent/index.d.cts +157 -0
- package/dist/device-agent/index.d.ts +157 -0
- package/dist/device-agent/index.js +498 -0
- package/dist/engine-CCSSqPbZ.d.cts +516 -0
- package/dist/engine-CCSSqPbZ.d.ts +516 -0
- package/dist/index.cjs +339 -5569
- package/dist/index.d.cts +22 -1406
- package/dist/index.d.ts +22 -1406
- package/dist/index.js +269 -5401
- package/dist/match/index.cjs +2456 -0
- package/dist/match/index.d.cts +253 -0
- package/dist/match/index.d.ts +253 -0
- package/dist/match/index.js +2456 -0
- package/dist/match/remote/index.cjs +21 -0
- package/dist/match/remote/index.d.cts +67 -0
- package/dist/match/remote/index.d.ts +67 -0
- package/dist/match/remote/index.js +21 -0
- package/dist/match/voice/index.cjs +23 -0
- package/dist/match/voice/index.d.cts +114 -0
- package/dist/match/voice/index.d.ts +114 -0
- package/dist/match/voice/index.js +23 -0
- package/dist/plugin.cjs +53 -70
- package/dist/plugin.d.cts +13 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.js +20 -15
- package/dist/types-CrmcX408.d.cts +176 -0
- package/dist/types-CrmcX408.d.ts +176 -0
- package/dist/types-DiKWjwvN.d.cts +96 -0
- package/dist/types-DiKWjwvN.d.ts +96 -0
- package/dist/version.cjs +4 -32
- package/dist/version.js +2 -1
- package/package.json +68 -4
- package/src/advanced.ts +47 -0
- package/src/device-agent/agent.ts +443 -0
- package/src/device-agent/component.tsx +49 -0
- package/src/device-agent/convex-control-channel.ts +82 -0
- package/src/device-agent/http-transport.ts +143 -0
- package/src/device-agent/index.ts +4 -0
- package/src/engine.ts +21 -5
- package/src/errors.ts +16 -0
- package/src/http-session-provider.ts +19 -13
- package/src/index.ts +38 -14
- package/src/livekit-room-adapter.ts +39 -2
- package/src/livestream-screen.tsx +26 -4
- package/src/match/index.ts +29 -0
- package/src/match/provider.tsx +4 -2
- package/src/match/remote/hid-gesture.ts +10 -2
- package/src/match/remote/types.ts +6 -4
- package/src/match/voice/scheduler.ts +6 -1
- package/src/match/voice/service.ts +16 -4
- package/src/native-runtime.ts +54 -4
- package/src/plugin.ts +35 -14
- package/src/provider.tsx +35 -4
- package/src/rtmp-room-adapter.ts +196 -0
- 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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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(),
|
package/src/native-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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>(
|
|
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
|
-
...
|
|
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
|
|
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
|
-
<
|
|
163
|
-
{
|
|
164
|
-
|
|
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
|
+
}
|
package/dist/chunk-VHLUJQG5.js
DELETED
|
@@ -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
|
-
};
|