@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app.plugin.js +6 -0
- package/dist/chunk-VHLUJQG5.js +17 -0
- package/dist/index.cjs +6284 -0
- package/dist/index.d.cts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +6186 -0
- package/dist/plugin.cjs +153 -0
- package/dist/plugin.d.cts +21 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +131 -0
- package/dist/version.cjs +35 -0
- package/dist/version.d.cts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +6 -0
- package/package.json +126 -0
- package/src/contracts.ts +211 -0
- package/src/engine.ts +1332 -0
- package/src/errors.ts +128 -0
- package/src/http-session-provider.ts +259 -0
- package/src/index.ts +16 -0
- package/src/livekit-room-adapter.ts +353 -0
- package/src/livestream-screen.tsx +980 -0
- package/src/match/contracts.ts +137 -0
- package/src/match/engine.ts +702 -0
- package/src/match/persistence/default-store.native.ts +20 -0
- package/src/match/persistence/default-store.ts +11 -0
- package/src/match/persistence/index.ts +6 -0
- package/src/match/persistence/memory-driver.ts +255 -0
- package/src/match/persistence/migrations.ts +40 -0
- package/src/match/persistence/sqlite-driver.ts +348 -0
- package/src/match/persistence/store.ts +497 -0
- package/src/match/persistence/types.ts +98 -0
- package/src/match/provider.tsx +728 -0
- package/src/match/remote/deduplicator.ts +54 -0
- package/src/match/remote/encoding.ts +12 -0
- package/src/match/remote/hid-gesture.ts +61 -0
- package/src/match/remote/index.ts +5 -0
- package/src/match/remote/native-service.ts +62 -0
- package/src/match/remote/parser.ts +134 -0
- package/src/match/remote/types.ts +116 -0
- package/src/match/replay.ts +102 -0
- package/src/match/state.ts +72 -0
- package/src/match/validation.ts +375 -0
- package/src/match/voice/assets/court-left.wav +0 -0
- package/src/match/voice/assets/court-right.wav +0 -0
- package/src/match/voice/assets/court-switch.wav +0 -0
- package/src/match/voice/assets/game-point.wav +0 -0
- package/src/match/voice/assets/game-won.wav +0 -0
- package/src/match/voice/assets/match-won.wav +0 -0
- package/src/match/voice/assets/number-0.wav +0 -0
- package/src/match/voice/assets/number-1.wav +0 -0
- package/src/match/voice/assets/number-10.wav +0 -0
- package/src/match/voice/assets/number-11.wav +0 -0
- package/src/match/voice/assets/number-12.wav +0 -0
- package/src/match/voice/assets/number-13.wav +0 -0
- package/src/match/voice/assets/number-14.wav +0 -0
- package/src/match/voice/assets/number-15.wav +0 -0
- package/src/match/voice/assets/number-16.wav +0 -0
- package/src/match/voice/assets/number-17.wav +0 -0
- package/src/match/voice/assets/number-18.wav +0 -0
- package/src/match/voice/assets/number-19.wav +0 -0
- package/src/match/voice/assets/number-2.wav +0 -0
- package/src/match/voice/assets/number-20.wav +0 -0
- package/src/match/voice/assets/number-3.wav +0 -0
- package/src/match/voice/assets/number-30.wav +0 -0
- package/src/match/voice/assets/number-4.wav +0 -0
- package/src/match/voice/assets/number-40.wav +0 -0
- package/src/match/voice/assets/number-5.wav +0 -0
- package/src/match/voice/assets/number-50.wav +0 -0
- package/src/match/voice/assets/number-6.wav +0 -0
- package/src/match/voice/assets/number-60.wav +0 -0
- package/src/match/voice/assets/number-7.wav +0 -0
- package/src/match/voice/assets/number-70.wav +0 -0
- package/src/match/voice/assets/number-8.wav +0 -0
- package/src/match/voice/assets/number-80.wav +0 -0
- package/src/match/voice/assets/number-9.wav +0 -0
- package/src/match/voice/assets/number-90.wav +0 -0
- package/src/match/voice/assets/number-lam.wav +0 -0
- package/src/match/voice/assets/number-mot.wav +0 -0
- package/src/match/voice/assets/score-deu-0.wav +0 -0
- package/src/match/voice/assets/score-deu-1.wav +0 -0
- package/src/match/voice/assets/score-deu-10.wav +0 -0
- package/src/match/voice/assets/score-deu-11.wav +0 -0
- package/src/match/voice/assets/score-deu-12.wav +0 -0
- package/src/match/voice/assets/score-deu-13.wav +0 -0
- package/src/match/voice/assets/score-deu-14.wav +0 -0
- package/src/match/voice/assets/score-deu-15.wav +0 -0
- package/src/match/voice/assets/score-deu-16.wav +0 -0
- package/src/match/voice/assets/score-deu-17.wav +0 -0
- package/src/match/voice/assets/score-deu-18.wav +0 -0
- package/src/match/voice/assets/score-deu-19.wav +0 -0
- package/src/match/voice/assets/score-deu-2.wav +0 -0
- package/src/match/voice/assets/score-deu-20.wav +0 -0
- package/src/match/voice/assets/score-deu-3.wav +0 -0
- package/src/match/voice/assets/score-deu-30.wav +0 -0
- package/src/match/voice/assets/score-deu-4.wav +0 -0
- package/src/match/voice/assets/score-deu-40.wav +0 -0
- package/src/match/voice/assets/score-deu-5.wav +0 -0
- package/src/match/voice/assets/score-deu-50.wav +0 -0
- package/src/match/voice/assets/score-deu-6.wav +0 -0
- package/src/match/voice/assets/score-deu-60.wav +0 -0
- package/src/match/voice/assets/score-deu-7.wav +0 -0
- package/src/match/voice/assets/score-deu-70.wav +0 -0
- package/src/match/voice/assets/score-deu-8.wav +0 -0
- package/src/match/voice/assets/score-deu-80.wav +0 -0
- package/src/match/voice/assets/score-deu-9.wav +0 -0
- package/src/match/voice/assets/score-deu-90.wav +0 -0
- package/src/match/voice/assets/score-deu-lam.wav +0 -0
- package/src/match/voice/assets/score-deu-mot.wav +0 -0
- package/src/match/voice/assets/server-1.wav +0 -0
- package/src/match/voice/assets/server-2.wav +0 -0
- package/src/match/voice/assets/service-change.wav +0 -0
- package/src/match/voice/assets/test-speaker.wav +0 -0
- package/src/match/voice/audio.ts +231 -0
- package/src/match/voice/bundled-assets.native.ts +47 -0
- package/src/match/voice/bundled-assets.ts +3 -0
- package/src/match/voice/gemini-engine.ts +167 -0
- package/src/match/voice/index.ts +7 -0
- package/src/match/voice/phrases.ts +185 -0
- package/src/match/voice/scheduler.ts +205 -0
- package/src/match/voice/service.ts +147 -0
- package/src/match/voice/types.ts +118 -0
- package/src/native-runtime.ts +241 -0
- package/src/plugin.ts +145 -0
- package/src/provider.tsx +176 -0
- package/src/version.ts +4 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { EngineDependencies, SdkNetwork, SdkRuntime, SdkStorage, TelemetrySink } from "./engine";
|
|
2
|
+
import { LiveKitRoomAdapter, type NativeLiveKitBindings } from "./livekit-room-adapter";
|
|
3
|
+
import { UnsupportedRuntimeError } from "./errors";
|
|
4
|
+
|
|
5
|
+
declare const require: (name: string) => any;
|
|
6
|
+
|
|
7
|
+
let globalsRegistered = false;
|
|
8
|
+
|
|
9
|
+
export function createNativeLiveKitBindings(): NativeLiveKitBindings {
|
|
10
|
+
const reactNativeLiveKit = require("@livekit/react-native");
|
|
11
|
+
const liveKit = require("livekit-client");
|
|
12
|
+
return {
|
|
13
|
+
registerGlobals() {
|
|
14
|
+
if (!globalsRegistered) {
|
|
15
|
+
reactNativeLiveKit.registerGlobals();
|
|
16
|
+
globalsRegistered = true;
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
createRoom: () => new liveKit.Room({ adaptiveStream: false, dynacast: false }),
|
|
20
|
+
createLocalTracks: liveKit.createLocalTracks,
|
|
21
|
+
startAudioSession: () => reactNativeLiveKit.AudioSession.startAudioSession(),
|
|
22
|
+
stopAudioSession: () => reactNativeLiveKit.AudioSession.stopAudioSession(),
|
|
23
|
+
isLandscape() {
|
|
24
|
+
const { width, height } = require("react-native").Dimensions.get("window");
|
|
25
|
+
return width > height;
|
|
26
|
+
},
|
|
27
|
+
async listCameras() {
|
|
28
|
+
const { mediaDevices } = require("@livekit/react-native-webrtc");
|
|
29
|
+
const devices: Array<{
|
|
30
|
+
kind?: string;
|
|
31
|
+
deviceId?: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
facing?: string;
|
|
34
|
+
}> = await mediaDevices.enumerateDevices();
|
|
35
|
+
return devices
|
|
36
|
+
.filter((device) => device.kind === "videoinput" && device.deviceId)
|
|
37
|
+
.map((device) => ({
|
|
38
|
+
deviceId: String(device.deviceId),
|
|
39
|
+
label: String(device.label ?? ""),
|
|
40
|
+
facing:
|
|
41
|
+
device.facing === "front" || device.facing === "environment"
|
|
42
|
+
? device.facing
|
|
43
|
+
: ("unknown" as const),
|
|
44
|
+
}));
|
|
45
|
+
},
|
|
46
|
+
cameraSource: liveKit.Track.Source.Camera,
|
|
47
|
+
events: {
|
|
48
|
+
reconnecting: liveKit.RoomEvent.Reconnecting,
|
|
49
|
+
reconnected: liveKit.RoomEvent.Reconnected,
|
|
50
|
+
disconnected: liveKit.RoomEvent.Disconnected,
|
|
51
|
+
mediaDevicesError: liveKit.RoomEvent.MediaDevicesError,
|
|
52
|
+
dataReceived: liveKit.RoomEvent.DataReceived,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createDefaultRuntime(): SdkRuntime {
|
|
58
|
+
const { NativeModules, Platform } = require("react-native");
|
|
59
|
+
return {
|
|
60
|
+
assertSupported() {
|
|
61
|
+
if (
|
|
62
|
+
(Platform.OS !== "ios" && Platform.OS !== "android") ||
|
|
63
|
+
!NativeModules.LivekitReactNativeModule
|
|
64
|
+
) {
|
|
65
|
+
throw new UnsupportedRuntimeError();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async requestPermissions() {
|
|
69
|
+
try {
|
|
70
|
+
const { mediaDevices } = require("@livekit/react-native-webrtc");
|
|
71
|
+
const stream = await mediaDevices.getUserMedia({ audio: true, video: true });
|
|
72
|
+
for (const track of stream.getTracks()) track.stop();
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
activateKeepAwake() {
|
|
79
|
+
return require("expo-keep-awake").activateKeepAwakeAsync(
|
|
80
|
+
"@pickleball/expo-sdk",
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
deactivateKeepAwake() {
|
|
84
|
+
return require("expo-keep-awake").deactivateKeepAwake(
|
|
85
|
+
"@pickleball/expo-sdk",
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createDefaultStorage(): SdkStorage {
|
|
92
|
+
require("expo-sqlite/localStorage/install");
|
|
93
|
+
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),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createDefaultNetwork(): SdkNetwork {
|
|
101
|
+
const NetInfo = require("@react-native-community/netinfo").default;
|
|
102
|
+
let online = true;
|
|
103
|
+
void NetInfo.fetch().then((state: { isConnected?: boolean | null }) => {
|
|
104
|
+
online = state.isConnected !== false;
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
isOnline: () => online,
|
|
108
|
+
subscribe(listener) {
|
|
109
|
+
return NetInfo.addEventListener((state: { isConnected?: boolean | null }) => {
|
|
110
|
+
online = state.isConnected !== false;
|
|
111
|
+
listener(online);
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createDefaultTelemetry(
|
|
118
|
+
fetchImplementation = globalThis.fetch,
|
|
119
|
+
timeoutMs = 10_000,
|
|
120
|
+
): TelemetrySink {
|
|
121
|
+
const controllers = new Set<AbortController>();
|
|
122
|
+
return {
|
|
123
|
+
async send(credentials, events) {
|
|
124
|
+
const body = JSON.stringify(events.slice(0, 20));
|
|
125
|
+
if (body.length > 24 * 1024) return;
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
controllers.add(controller);
|
|
128
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetchImplementation(credentials.endpoint, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
Authorization: `Bearer ${credentials.token}`,
|
|
135
|
+
},
|
|
136
|
+
body,
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
redirect: "error",
|
|
139
|
+
});
|
|
140
|
+
if (typeof response.text === "function") await response.text();
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw Object.assign(new Error("Telemetry rejected"), {
|
|
143
|
+
status: response.status,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
controllers.delete(controller);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
dispose() {
|
|
152
|
+
for (const controller of controllers) controller.abort();
|
|
153
|
+
controllers.clear();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createDefaultRoomFactory(): EngineDependencies["roomFactory"] {
|
|
159
|
+
return () => new LiveKitRoomAdapter(createNativeLiveKitBindings());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Thu chỉ số thiết bị cho heartbeat. Mỗi nguồn là optional — thiếu package
|
|
164
|
+
* (expo-battery/expo-device/expo-file-system) thì bỏ qua trường đó thay vì lỗi.
|
|
165
|
+
*/
|
|
166
|
+
export function createDefaultDeviceMetrics(): NonNullable<
|
|
167
|
+
EngineDependencies["deviceMetrics"]
|
|
168
|
+
> {
|
|
169
|
+
return async () => {
|
|
170
|
+
const metrics: Record<string, unknown> = {};
|
|
171
|
+
// TODO(debug): gỡ mảng notes + log sau khi xác minh collector trên thiết bị.
|
|
172
|
+
const notes: string[] = [];
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const Battery = require("expo-battery");
|
|
176
|
+
const level = await Battery.getBatteryLevelAsync();
|
|
177
|
+
if (typeof level === "number" && level >= 0) {
|
|
178
|
+
metrics.batteryPct = Math.round(level * 100);
|
|
179
|
+
} else {
|
|
180
|
+
notes.push(`battery level=${String(level)}`);
|
|
181
|
+
}
|
|
182
|
+
const state = await Battery.getBatteryStateAsync();
|
|
183
|
+
// BatteryState.CHARGING = 2, FULL = 3 (đang cắm nguồn).
|
|
184
|
+
if (typeof state === "number") metrics.charging = state === 2 || state === 3;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
notes.push(`battery require/call: ${String(error)}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
let FileSystem: {
|
|
191
|
+
getFreeDiskStorageAsync?: () => Promise<number>;
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
FileSystem = require("expo-file-system/legacy");
|
|
195
|
+
} catch {
|
|
196
|
+
FileSystem = require("expo-file-system");
|
|
197
|
+
}
|
|
198
|
+
if (FileSystem.getFreeDiskStorageAsync) {
|
|
199
|
+
const freeBytes = await FileSystem.getFreeDiskStorageAsync();
|
|
200
|
+
metrics.storageFreeGb = Math.round(freeBytes / 1e8) / 10;
|
|
201
|
+
} else {
|
|
202
|
+
notes.push("filesystem: getFreeDiskStorageAsync missing");
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
notes.push(`filesystem: ${String(error)}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const Device = require("expo-device");
|
|
210
|
+
if (typeof Device.totalMemory === "number") {
|
|
211
|
+
metrics.ramTotalMb = Math.round(Device.totalMemory / 1_048_576);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
/* expo-device chưa được cài */
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const NetInfo = require("@react-native-community/netinfo").default;
|
|
219
|
+
const status = await NetInfo.fetch();
|
|
220
|
+
if (status?.type) {
|
|
221
|
+
metrics.netType =
|
|
222
|
+
status.type === "wifi"
|
|
223
|
+
? "WiFi"
|
|
224
|
+
: status.type === "cellular"
|
|
225
|
+
? String(
|
|
226
|
+
status.details?.cellularGeneration ?? "cellular",
|
|
227
|
+
).toUpperCase()
|
|
228
|
+
: status.type;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
/* netinfo không khả dụng */
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// TODO(debug): gỡ sau khi xác minh collector trên thiết bị.
|
|
235
|
+
if (notes.length > 0) console.log("[pickleball] metrics notes:", notes.join(" | "));
|
|
236
|
+
|
|
237
|
+
return Object.keys(metrics).length > 0
|
|
238
|
+
? (metrics as import("./engine").DeviceMetricsSample)
|
|
239
|
+
: null;
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AndroidConfig,
|
|
3
|
+
createRunOncePlugin,
|
|
4
|
+
withAndroidManifest,
|
|
5
|
+
withDangerousMod,
|
|
6
|
+
withInfoPlist,
|
|
7
|
+
withPlugins,
|
|
8
|
+
type ConfigPlugin,
|
|
9
|
+
} from "expo/config-plugins";
|
|
10
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { PICKLEBALL_EXPO_SDK_VERSION } from "./version";
|
|
13
|
+
|
|
14
|
+
export interface PickleballLivePluginProps {
|
|
15
|
+
cameraPermission?: string;
|
|
16
|
+
microphonePermission?: string;
|
|
17
|
+
bluetoothPermission?: string;
|
|
18
|
+
consumeHidVolumeKeys?: boolean;
|
|
19
|
+
liveKit?: {
|
|
20
|
+
android?: { audioType?: string; enableScreenShareService?: boolean };
|
|
21
|
+
ios?: { enableMultitaskingCameraAccess?: boolean };
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ANDROID_PERMISSIONS = [
|
|
26
|
+
"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
|
+
"android.permission.CAMERA",
|
|
33
|
+
"android.permission.INTERNET",
|
|
34
|
+
"android.permission.MODIFY_AUDIO_SETTINGS",
|
|
35
|
+
"android.permission.RECORD_AUDIO",
|
|
36
|
+
"android.permission.WAKE_LOCK",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export function injectHidVolumeMainActivity(source: string): string {
|
|
40
|
+
if (source.includes("PickleballRemoteHidDispatcher.dispatch(event)")) return source;
|
|
41
|
+
if (!/class\s+MainActivity\s*:\s*ReactActivity\(\)\s*\{/.test(source)) {
|
|
42
|
+
throw new Error("Pickleball HID volume integration requires a generated Kotlin MainActivity");
|
|
43
|
+
}
|
|
44
|
+
if (/override\s+fun\s+dispatchKeyEvent/.test(source)) {
|
|
45
|
+
throw new Error("MainActivity already overrides dispatchKeyEvent; forward it to PickleballRemoteHidDispatcher manually");
|
|
46
|
+
}
|
|
47
|
+
let updated = source;
|
|
48
|
+
if (!updated.includes("import android.view.KeyEvent")) {
|
|
49
|
+
updated = updated.replace(/(package\s+[^\n]+\n)/, "$1\nimport android.view.KeyEvent\nimport expo.modules.pickleballremote.PickleballRemoteHidDispatcher\n");
|
|
50
|
+
}
|
|
51
|
+
const closing = updated.lastIndexOf("}");
|
|
52
|
+
return `${updated.slice(0, closing)}\n override fun dispatchKeyEvent(event: KeyEvent): Boolean {\n if (PickleballRemoteHidDispatcher.dispatch(event)) return true\n return super.dispatchKeyEvent(event)\n }\n${updated.slice(closing)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function findMainActivity(directory: string): Promise<string | null> {
|
|
56
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
57
|
+
const path = join(directory, entry.name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
const found = await findMainActivity(path);
|
|
60
|
+
if (found) return found;
|
|
61
|
+
} else if (entry.name === "MainActivity.kt") return path;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> = (
|
|
67
|
+
config,
|
|
68
|
+
props = {},
|
|
69
|
+
) => {
|
|
70
|
+
const cameraPermission =
|
|
71
|
+
props?.cameraPermission ??
|
|
72
|
+
"Allow $(PRODUCT_NAME) to use the camera for livestreaming.";
|
|
73
|
+
const microphonePermission =
|
|
74
|
+
props?.microphonePermission ??
|
|
75
|
+
"Allow $(PRODUCT_NAME) to use the microphone for livestreaming.";
|
|
76
|
+
const bluetoothPermission =
|
|
77
|
+
props?.bluetoothPermission ??
|
|
78
|
+
"Cho phép $(PRODUCT_NAME) dùng Bluetooth để nhận thao tác từ nút điều khiển trên sân.";
|
|
79
|
+
|
|
80
|
+
config = withPlugins(config, [
|
|
81
|
+
["@livekit/react-native-expo-plugin", props?.liveKit],
|
|
82
|
+
]);
|
|
83
|
+
config.ios = { ...config.ios, bitcode: false };
|
|
84
|
+
config = withInfoPlist(config, (mod) => {
|
|
85
|
+
mod.modResults.NSCameraUsageDescription = cameraPermission;
|
|
86
|
+
mod.modResults.NSMicrophoneUsageDescription = microphonePermission;
|
|
87
|
+
mod.modResults.NSBluetoothAlwaysUsageDescription = bluetoothPermission;
|
|
88
|
+
return mod;
|
|
89
|
+
});
|
|
90
|
+
config = AndroidConfig.Permissions.withPermissions(config, [
|
|
91
|
+
...ANDROID_PERMISSIONS,
|
|
92
|
+
]);
|
|
93
|
+
config = withAndroidManifest(config, (mod) => {
|
|
94
|
+
const manifest = mod.modResults.manifest;
|
|
95
|
+
const existing = manifest["uses-permission"] ?? [];
|
|
96
|
+
const requiredAttributes: Record<string, Record<string, string>> = {
|
|
97
|
+
"android.permission.BLUETOOTH": { "android:maxSdkVersion": "30" },
|
|
98
|
+
"android.permission.BLUETOOTH_ADMIN": { "android:maxSdkVersion": "30" },
|
|
99
|
+
"android.permission.ACCESS_FINE_LOCATION": { "android:maxSdkVersion": "30" },
|
|
100
|
+
"android.permission.BLUETOOTH_SCAN": {
|
|
101
|
+
"android:usesPermissionFlags": "neverForLocation",
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const byName = new Map<string, (typeof existing)[number]>();
|
|
105
|
+
for (const entry of existing) {
|
|
106
|
+
const name = entry.$?.["android:name"];
|
|
107
|
+
if (name && !byName.has(name)) byName.set(name, entry);
|
|
108
|
+
}
|
|
109
|
+
for (const permission of ANDROID_PERMISSIONS) {
|
|
110
|
+
const entry = byName.get(permission) ?? { $: { "android:name": permission } };
|
|
111
|
+
entry.$ = {
|
|
112
|
+
"android:name": permission,
|
|
113
|
+
...(requiredAttributes[permission] ?? {}),
|
|
114
|
+
};
|
|
115
|
+
byName.set(permission, entry);
|
|
116
|
+
}
|
|
117
|
+
const requiredNames = new Set<string>(ANDROID_PERMISSIONS);
|
|
118
|
+
manifest["uses-permission"] = [
|
|
119
|
+
...existing.filter((entry) => {
|
|
120
|
+
const name = entry.$?.["android:name"];
|
|
121
|
+
return !name || !requiredNames.has(name);
|
|
122
|
+
}),
|
|
123
|
+
...ANDROID_PERMISSIONS.map((name) => byName.get(name)!),
|
|
124
|
+
];
|
|
125
|
+
return mod;
|
|
126
|
+
});
|
|
127
|
+
if (props?.consumeHidVolumeKeys !== false) {
|
|
128
|
+
config = withDangerousMod(config, ["android", async (mod) => {
|
|
129
|
+
const root = join(mod.modRequest.platformProjectRoot, "app", "src", "main", "java");
|
|
130
|
+
const path = await findMainActivity(root);
|
|
131
|
+
if (!path) throw new Error("Generated Android MainActivity.kt was not found");
|
|
132
|
+
const source = await readFile(path, "utf8");
|
|
133
|
+
const updated = injectHidVolumeMainActivity(source);
|
|
134
|
+
if (updated !== source) await writeFile(path, updated);
|
|
135
|
+
return mod;
|
|
136
|
+
}]);
|
|
137
|
+
}
|
|
138
|
+
return config;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export default createRunOncePlugin(
|
|
142
|
+
withPickleballLive,
|
|
143
|
+
"@pickleball/expo-sdk",
|
|
144
|
+
PICKLEBALL_EXPO_SDK_VERSION,
|
|
145
|
+
);
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
2
|
+
import { AppState } from "react-native";
|
|
3
|
+
import type { BootstrapInput, LivestreamSessionProvider, StartLivestreamInput, EndReason } from "./contracts";
|
|
4
|
+
import { ExpoSdkConfigurationError, type PickleballExpoError } from "./errors";
|
|
5
|
+
import {
|
|
6
|
+
LivestreamEngine,
|
|
7
|
+
type EngineDependencies,
|
|
8
|
+
type LivestreamSnapshot,
|
|
9
|
+
type RoomAdapter,
|
|
10
|
+
type SdkNetwork,
|
|
11
|
+
type SdkRuntime,
|
|
12
|
+
type SdkStorage,
|
|
13
|
+
type TelemetrySink,
|
|
14
|
+
} from "./engine";
|
|
15
|
+
import {
|
|
16
|
+
createDefaultDeviceMetrics,
|
|
17
|
+
createDefaultNetwork,
|
|
18
|
+
createDefaultRoomFactory,
|
|
19
|
+
createDefaultRuntime,
|
|
20
|
+
createDefaultStorage,
|
|
21
|
+
createDefaultTelemetry,
|
|
22
|
+
} from "./native-runtime";
|
|
23
|
+
|
|
24
|
+
export interface LivestreamTheme {
|
|
25
|
+
background: string;
|
|
26
|
+
foreground: string;
|
|
27
|
+
accent: string;
|
|
28
|
+
danger: string;
|
|
29
|
+
/** Chữ phụ mờ nhất (labels, chú thích). */
|
|
30
|
+
muted: string;
|
|
31
|
+
overlay: string;
|
|
32
|
+
radius: number;
|
|
33
|
+
/** Token FintechX — đồng bộ với dashboard web. */
|
|
34
|
+
surface: string;
|
|
35
|
+
elevated: string;
|
|
36
|
+
hairline: string;
|
|
37
|
+
/** Màu chữ đặt trên nền accent. */
|
|
38
|
+
ink: string;
|
|
39
|
+
textSecondary: string;
|
|
40
|
+
radiusSm: number;
|
|
41
|
+
fontFamily?: string;
|
|
42
|
+
fontFamilyBold?: string;
|
|
43
|
+
displayFontFamily?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** FintechX design tokens — trùng bộ token trong apps/web/globals.css. */
|
|
47
|
+
export const defaultLivestreamTheme: LivestreamTheme = {
|
|
48
|
+
background: "#0a0a0b",
|
|
49
|
+
foreground: "#f5f6f7",
|
|
50
|
+
accent: "#b4ff39",
|
|
51
|
+
danger: "#fb7185",
|
|
52
|
+
muted: "#6c7079",
|
|
53
|
+
overlay: "rgba(10,10,11,0.82)",
|
|
54
|
+
radius: 14,
|
|
55
|
+
surface: "#131417",
|
|
56
|
+
elevated: "#1b1d21",
|
|
57
|
+
hairline: "rgba(255,255,255,0.09)",
|
|
58
|
+
ink: "#0a0a0b",
|
|
59
|
+
textSecondary: "#a7abb3",
|
|
60
|
+
radiusSm: 6,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface PickleballLiveProviderProps {
|
|
64
|
+
children?: ReactNode;
|
|
65
|
+
sessionProvider: LivestreamSessionProvider;
|
|
66
|
+
bootstrapInput: BootstrapInput;
|
|
67
|
+
runtime?: SdkRuntime;
|
|
68
|
+
storage?: SdkStorage;
|
|
69
|
+
network?: SdkNetwork;
|
|
70
|
+
telemetry?: TelemetrySink;
|
|
71
|
+
roomFactory?: () => RoomAdapter;
|
|
72
|
+
deviceMetrics?: EngineDependencies["deviceMetrics"];
|
|
73
|
+
theme?: Partial<LivestreamTheme>;
|
|
74
|
+
locale?: "vi" | "en";
|
|
75
|
+
onError?: (error: PickleballExpoError) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LivestreamContextValue extends LivestreamSnapshot {
|
|
79
|
+
locale: "vi" | "en";
|
|
80
|
+
theme: LivestreamTheme;
|
|
81
|
+
prepare(): Promise<void>;
|
|
82
|
+
start(
|
|
83
|
+
input: StartLivestreamInput,
|
|
84
|
+
options?: { autoPublish?: boolean },
|
|
85
|
+
): Promise<void>;
|
|
86
|
+
/** Bắt đầu phát khi đang standby. */
|
|
87
|
+
startPublishing(): Promise<void>;
|
|
88
|
+
/** Tạm dừng phát về standby (không kết thúc phiên). */
|
|
89
|
+
stopPublishing(): Promise<void>;
|
|
90
|
+
stop(reason?: EndReason): Promise<void>;
|
|
91
|
+
retry(): Promise<void>;
|
|
92
|
+
switchCamera(): Promise<void>;
|
|
93
|
+
setMicrophoneEnabled(enabled: boolean): Promise<void>;
|
|
94
|
+
setCaptureOrientation(orientation: "portrait" | "landscape"): Promise<void>;
|
|
95
|
+
selectCamera(deviceId: string): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const LivestreamContext = React.createContext<LivestreamContextValue | null>(null);
|
|
99
|
+
|
|
100
|
+
export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
|
|
101
|
+
const engineRef = useRef<LivestreamEngine | null>(null);
|
|
102
|
+
const effectGenerationRef = useRef(0);
|
|
103
|
+
if (!engineRef.current) {
|
|
104
|
+
const dependencies: EngineDependencies = {
|
|
105
|
+
sessionProvider: props.sessionProvider,
|
|
106
|
+
bootstrapInput: props.bootstrapInput,
|
|
107
|
+
runtime: props.runtime ?? createDefaultRuntime(),
|
|
108
|
+
storage: props.storage ?? createDefaultStorage(),
|
|
109
|
+
network: props.network ?? createDefaultNetwork(),
|
|
110
|
+
telemetry: props.telemetry ?? createDefaultTelemetry(),
|
|
111
|
+
deviceMetrics: props.deviceMetrics ?? createDefaultDeviceMetrics(),
|
|
112
|
+
roomFactory: props.roomFactory ?? createDefaultRoomFactory(),
|
|
113
|
+
};
|
|
114
|
+
engineRef.current = new LivestreamEngine(dependencies);
|
|
115
|
+
}
|
|
116
|
+
const engine = engineRef.current;
|
|
117
|
+
const snapshot = useSyncExternalStore(
|
|
118
|
+
engine.subscribe,
|
|
119
|
+
engine.getSnapshot,
|
|
120
|
+
engine.getSnapshot,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const effectGeneration = ++effectGenerationRef.current;
|
|
125
|
+
void engine.initialize();
|
|
126
|
+
const subscription = AppState.addEventListener("change", (state) => {
|
|
127
|
+
engine.handleAppState(
|
|
128
|
+
state === "active" ? "active" : state === "background" ? "background" : "inactive",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
return () => {
|
|
132
|
+
subscription.remove();
|
|
133
|
+
queueMicrotask(() => {
|
|
134
|
+
if (effectGenerationRef.current === effectGeneration) {
|
|
135
|
+
void engine.dispose();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
}, [engine]);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (snapshot.error) props.onError?.(snapshot.error);
|
|
143
|
+
}, [props.onError, snapshot.error]);
|
|
144
|
+
|
|
145
|
+
const value: LivestreamContextValue = {
|
|
146
|
+
...snapshot,
|
|
147
|
+
locale: props.locale ?? "vi",
|
|
148
|
+
theme: { ...defaultLivestreamTheme, ...props.theme },
|
|
149
|
+
prepare: () => engine.prepare(),
|
|
150
|
+
start: (input, options) => engine.start(input, options),
|
|
151
|
+
startPublishing: () => engine.startPublishing(),
|
|
152
|
+
stopPublishing: () => engine.stopPublishing(),
|
|
153
|
+
stop: (reason) => engine.stop(reason),
|
|
154
|
+
retry: () => engine.retry(),
|
|
155
|
+
switchCamera: () => engine.switchCamera(),
|
|
156
|
+
setMicrophoneEnabled: (enabled) => engine.setMicrophoneEnabled(enabled),
|
|
157
|
+
setCaptureOrientation: (orientation) =>
|
|
158
|
+
engine.setCaptureOrientation(orientation),
|
|
159
|
+
selectCamera: (deviceId) => engine.selectCamera(deviceId),
|
|
160
|
+
};
|
|
161
|
+
return (
|
|
162
|
+
<LivestreamContext.Provider value={value}>
|
|
163
|
+
{props.children}
|
|
164
|
+
</LivestreamContext.Provider>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function useLivestream() {
|
|
169
|
+
const value = React.use(LivestreamContext);
|
|
170
|
+
if (!value) {
|
|
171
|
+
throw new ExpoSdkConfigurationError(
|
|
172
|
+
"useLivestream must be used inside PickleballLiveProvider",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return value;
|
|
176
|
+
}
|
package/src/version.ts
ADDED