@pickleball/expo-sdk 0.2.0 → 1.0.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 +41 -0
- package/README.md +155 -101
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +14 -2
- package/dist/advanced.d.cts +2 -2
- package/dist/advanced.d.ts +2 -2
- package/dist/advanced.js +17 -5
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-62YV3PCD.cjs +139 -0
- package/dist/chunk-6F3HN6US.js +50 -0
- package/dist/chunk-6YE5BL3A.cjs +50 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
- package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
- package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
- package/dist/chunk-ZSKSXOCZ.js +139 -0
- package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
- package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
- package/dist/device-agent/index.cjs +973 -44
- package/dist/device-agent/index.d.cts +339 -3
- package/dist/device-agent/index.d.ts +339 -3
- package/dist/device-agent/index.js +940 -11
- package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
- package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
- package/dist/index.cjs +419 -937
- package/dist/index.d.cts +315 -32
- package/dist/index.d.ts +315 -32
- package/dist/index.js +423 -941
- package/dist/plugin.cjs +11 -13
- package/dist/plugin.d.cts +15 -1
- package/dist/plugin.d.ts +15 -1
- package/dist/plugin.js +7 -9
- package/dist/realtime/index.cjs +31 -0
- package/dist/realtime/index.d.cts +15 -0
- package/dist/realtime/index.d.ts +15 -0
- package/dist/realtime/index.js +31 -0
- package/dist/ui/index.cjs +1297 -0
- package/dist/ui/index.d.cts +493 -0
- package/dist/ui/index.d.ts +493 -0
- package/dist/ui/index.js +1297 -0
- package/dist/version.cjs +2 -2
- package/dist/version.js +1 -1
- package/package.json +31 -15
- package/src/advanced.ts +11 -9
- package/src/auto-resume.tsx +259 -0
- package/src/camera-controls.ts +99 -0
- package/src/camera-grant-provider.ts +392 -0
- package/src/camera-settings.ts +133 -0
- package/src/contracts.ts +73 -0
- package/src/device-agent/agent.ts +262 -9
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +11 -0
- package/src/device-agent/convex-beat-transport.ts +140 -0
- package/src/device-agent/convex-command-subscription.ts +101 -0
- package/src/device-agent/convex-control-channel.ts +7 -1
- package/src/device-agent/http-transport.ts +22 -1
- package/src/device-agent/index.ts +22 -0
- package/src/device-agent/mqtt-channel.ts +379 -0
- package/src/device-agent/mqtt-codec.ts +347 -0
- package/src/device-agent/tournament.ts +352 -0
- package/src/engine.ts +580 -15
- package/src/http-session-provider.ts +21 -189
- package/src/index.ts +84 -5
- package/src/local-video-quality.ts +43 -0
- package/src/native-runtime.ts +234 -78
- package/src/plugin.ts +19 -6
- package/src/provider.tsx +94 -18
- package/src/realtime/index.ts +56 -0
- package/src/resume-policy.ts +85 -0
- package/src/resume.ts +132 -0
- package/src/rtmp-room-adapter.ts +164 -12
- package/src/session-grant.ts +256 -0
- package/src/ui/camera-screen.tsx +484 -0
- package/src/ui/controls-sheet.tsx +445 -0
- package/src/ui/copy.ts +326 -0
- package/src/ui/helpers.ts +105 -0
- package/src/ui/hooks.ts +59 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/preview-view.tsx +39 -0
- package/src/ui/primitives.tsx +304 -0
- package/src/uplink-check.ts +173 -0
- package/dist/advanced-BXDrDl1h.d.ts +0 -316
- package/dist/advanced-BdAa2Erv.d.cts +0 -316
- package/src/livekit-room-adapter.ts +0 -390
- package/src/livestream-screen.tsx +0 -1002
package/src/native-runtime.ts
CHANGED
|
@@ -1,94 +1,86 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
EngineDependencies,
|
|
3
|
+
SdkNetwork,
|
|
4
|
+
SdkRuntime,
|
|
5
|
+
SdkStorage,
|
|
6
|
+
SessionCommandChannel,
|
|
7
|
+
TelemetrySink,
|
|
8
|
+
} from "./engine";
|
|
9
|
+
import type {
|
|
10
|
+
SessionCommandPollResult,
|
|
11
|
+
SessionCommandRecord,
|
|
12
|
+
} from "./contracts";
|
|
3
13
|
import { RtmpRoomAdapter, type NativeRtmpBindings } from "./rtmp-room-adapter";
|
|
4
14
|
import { UnsupportedRuntimeError } from "./errors";
|
|
5
15
|
|
|
6
16
|
declare const require: (name: string) => any;
|
|
7
17
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
globalsRegistered = true;
|
|
18
|
-
}
|
|
19
|
-
},
|
|
20
|
-
createRoom: () => new liveKit.Room({ adaptiveStream: false, dynacast: false }),
|
|
21
|
-
createLocalTracks: liveKit.createLocalTracks,
|
|
22
|
-
startAudioSession: () => reactNativeLiveKit.AudioSession.startAudioSession(),
|
|
23
|
-
stopAudioSession: () => reactNativeLiveKit.AudioSession.stopAudioSession(),
|
|
24
|
-
isLandscape() {
|
|
25
|
-
const { width, height } = require("react-native").Dimensions.get("window");
|
|
26
|
-
return width > height;
|
|
27
|
-
},
|
|
28
|
-
async listCameras() {
|
|
29
|
-
const { mediaDevices } = require("@livekit/react-native-webrtc");
|
|
30
|
-
const devices: Array<{
|
|
31
|
-
kind?: string;
|
|
32
|
-
deviceId?: string;
|
|
33
|
-
label?: string;
|
|
34
|
-
facing?: string;
|
|
35
|
-
}> = await mediaDevices.enumerateDevices();
|
|
36
|
-
return devices
|
|
37
|
-
.filter((device) => device.kind === "videoinput" && device.deviceId)
|
|
38
|
-
.map((device) => ({
|
|
39
|
-
deviceId: String(device.deviceId),
|
|
40
|
-
label: String(device.label ?? ""),
|
|
41
|
-
facing:
|
|
42
|
-
device.facing === "front" || device.facing === "environment"
|
|
43
|
-
? device.facing
|
|
44
|
-
: ("unknown" as const),
|
|
45
|
-
}));
|
|
46
|
-
},
|
|
47
|
-
cameraSource: liveKit.Track.Source.Camera,
|
|
48
|
-
events: {
|
|
49
|
-
reconnecting: liveKit.RoomEvent.Reconnecting,
|
|
50
|
-
reconnected: liveKit.RoomEvent.Reconnected,
|
|
51
|
-
disconnected: liveKit.RoomEvent.Disconnected,
|
|
52
|
-
mediaDevicesError: liveKit.RoomEvent.MediaDevicesError,
|
|
53
|
-
dataReceived: liveKit.RoomEvent.DataReceived,
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function createDefaultRuntime(): SdkRuntime {
|
|
59
|
-
const { NativeModules, Platform } = require("react-native");
|
|
18
|
+
/**
|
|
19
|
+
* Runtime cho app RTMP-only (KHÔNG có LiveKit/WebRTC): assert bằng module
|
|
20
|
+
* @pickleball/rtmp-native, xin quyền bằng API quyền thuần (Android) hoặc hàm
|
|
21
|
+
* native `requestPermissions` (iOS, rtmp-native ≥ 0.2; thiếu thì AVFoundation
|
|
22
|
+
* hỏi lúc mở camera). Chuyển từ apps/device-streamer (createRtmpOnlyRuntime).
|
|
23
|
+
* Không dùng getUserMedia: máy MediaTek chỉ cho một camera mở cùng lúc.
|
|
24
|
+
*/
|
|
25
|
+
export function createRtmpRuntime(): SdkRuntime {
|
|
26
|
+
const { Platform, PermissionsAndroid } = require("react-native");
|
|
60
27
|
return {
|
|
61
28
|
assertSupported() {
|
|
62
|
-
if (
|
|
63
|
-
(Platform.OS !== "ios" && Platform.OS !== "android") ||
|
|
64
|
-
!NativeModules.LivekitReactNativeModule
|
|
65
|
-
) {
|
|
29
|
+
if (Platform.OS !== "ios" && Platform.OS !== "android") {
|
|
66
30
|
throw new UnsupportedRuntimeError();
|
|
67
31
|
}
|
|
32
|
+
try {
|
|
33
|
+
require("@pickleball/rtmp-native");
|
|
34
|
+
} catch {
|
|
35
|
+
throw new UnsupportedRuntimeError(
|
|
36
|
+
"@pickleball/rtmp-native is required: install it and rebuild the Development Build",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
68
39
|
},
|
|
69
40
|
async requestPermissions() {
|
|
41
|
+
if (Platform.OS === "android") {
|
|
42
|
+
try {
|
|
43
|
+
const granted = await PermissionsAndroid.requestMultiple([
|
|
44
|
+
PermissionsAndroid.PERMISSIONS.CAMERA,
|
|
45
|
+
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
|
|
46
|
+
]);
|
|
47
|
+
return (
|
|
48
|
+
granted[PermissionsAndroid.PERMISSIONS.CAMERA] ===
|
|
49
|
+
PermissionsAndroid.RESULTS.GRANTED &&
|
|
50
|
+
granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
|
|
51
|
+
PermissionsAndroid.RESULTS.GRANTED
|
|
52
|
+
);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
70
57
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
58
|
+
const native = require("@pickleball/rtmp-native").default;
|
|
59
|
+
if (typeof native?.requestPermissions === "function") {
|
|
60
|
+
return (await native.requestPermissions()) !== false;
|
|
61
|
+
}
|
|
75
62
|
} catch {
|
|
76
|
-
|
|
63
|
+
// Binary cũ chưa có hàm → AVFoundation tự hỏi lúc prepare.
|
|
77
64
|
}
|
|
65
|
+
return true;
|
|
78
66
|
},
|
|
79
|
-
activateKeepAwake()
|
|
80
|
-
|
|
81
|
-
"@pickleball/expo-sdk",
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
67
|
+
activateKeepAwake: () =>
|
|
68
|
+
Promise.resolve(
|
|
69
|
+
require("expo-keep-awake").activateKeepAwakeAsync("@pickleball/expo-sdk"),
|
|
70
|
+
).catch(() => undefined),
|
|
71
|
+
// deactivate trả promise và REJECT khi activity đã chết (sau crash/reload) —
|
|
72
|
+
// không catch là thêm một "Uncaught (in promise)" gây nhiễu log.
|
|
73
|
+
deactivateKeepAwake: () => {
|
|
74
|
+
void Promise.resolve(
|
|
75
|
+
require("expo-keep-awake").deactivateKeepAwake("@pickleball/expo-sdk"),
|
|
76
|
+
).catch(() => undefined);
|
|
88
77
|
},
|
|
89
78
|
};
|
|
90
79
|
}
|
|
91
80
|
|
|
81
|
+
/** Runtime mặc định của SDK 1.0 = RTMP (LiveKit đã gỡ khỏi SDK — ADR-0002). */
|
|
82
|
+
export const createDefaultRuntime = createRtmpRuntime;
|
|
83
|
+
|
|
92
84
|
export function createDefaultStorage(): SdkStorage {
|
|
93
85
|
// Cài localStorage của expo-sqlite ở LẦN DÙNG ĐẦU, không phải lúc gọi factory:
|
|
94
86
|
// app thường tạo storage ở module scope (root layout), và static-render web
|
|
@@ -174,19 +166,122 @@ export function createDefaultTelemetry(
|
|
|
174
166
|
};
|
|
175
167
|
}
|
|
176
168
|
|
|
169
|
+
/** roomFactory mặc định của SDK 1.0 = RTMP (fps/camera mặc định của native). */
|
|
177
170
|
export function createDefaultRoomFactory(): EngineDependencies["roomFactory"] {
|
|
178
|
-
return ()
|
|
171
|
+
return createRtmpRoomFactory();
|
|
179
172
|
}
|
|
180
173
|
|
|
181
174
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
175
|
+
* Kênh poll lệnh cho phiên device-rtmp. URL suy từ telemetry endpoint
|
|
176
|
+
* (.../telemetry → .../commands) — cùng origin, cùng Bearer token, nên không
|
|
177
|
+
* cần thêm bất kỳ trường mới nào đi qua proxy của đối tác.
|
|
178
|
+
*/
|
|
179
|
+
export function createDefaultCommandChannel(
|
|
180
|
+
fetchImplementation = globalThis.fetch,
|
|
181
|
+
timeoutMs = 10_000,
|
|
182
|
+
): SessionCommandChannel {
|
|
183
|
+
const controllers = new Set<AbortController>();
|
|
184
|
+
return {
|
|
185
|
+
async poll(credentials, after): Promise<SessionCommandPollResult> {
|
|
186
|
+
const endpoint = credentials.endpoint.replace(/\/telemetry$/, "/commands");
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
controllers.add(controller);
|
|
189
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
190
|
+
try {
|
|
191
|
+
const response = await fetchImplementation(endpoint, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
Authorization: `Bearer ${credentials.token}`,
|
|
196
|
+
},
|
|
197
|
+
body: JSON.stringify({ after }),
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
redirect: "error",
|
|
200
|
+
});
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
if (typeof response.text === "function") await response.text();
|
|
203
|
+
throw Object.assign(new Error("Command poll rejected"), {
|
|
204
|
+
status: response.status,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const envelope = (await response.json()) as { data?: unknown };
|
|
208
|
+
return parseCommandPollResult(envelope?.data);
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timeout);
|
|
211
|
+
controllers.delete(controller);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
dispose() {
|
|
215
|
+
for (const controller of controllers) controller.abort();
|
|
216
|
+
controllers.clear();
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function parseCommandPollResult(value: unknown): SessionCommandPollResult {
|
|
222
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
223
|
+
throw new Error("Command poll returned an invalid payload");
|
|
224
|
+
}
|
|
225
|
+
const record = value as Record<string, unknown>;
|
|
226
|
+
const status = record.status;
|
|
227
|
+
if (status !== "scheduled" && status !== "live" && status !== "ended") {
|
|
228
|
+
throw new Error("Command poll returned an invalid status");
|
|
229
|
+
}
|
|
230
|
+
const commands: SessionCommandRecord[] = [];
|
|
231
|
+
if (Array.isArray(record.commands)) {
|
|
232
|
+
for (const item of record.commands) {
|
|
233
|
+
if (item === null || typeof item !== "object") continue;
|
|
234
|
+
const command = item as Record<string, unknown>;
|
|
235
|
+
if (
|
|
236
|
+
typeof command.id !== "string" ||
|
|
237
|
+
(command.action !== "start" &&
|
|
238
|
+
command.action !== "stop" &&
|
|
239
|
+
command.action !== "end") ||
|
|
240
|
+
typeof command.issuedAt !== "number" ||
|
|
241
|
+
!Number.isFinite(command.issuedAt)
|
|
242
|
+
) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
commands.push({
|
|
246
|
+
id: command.id,
|
|
247
|
+
action: command.action,
|
|
248
|
+
issuedAt: command.issuedAt,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { commands, status };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Bindings native RTMP. `require` động: app chưa cài @pickleball/rtmp-native
|
|
257
|
+
* chỉ lỗi khi thật sự mở camera (UnsupportedRuntimeError rõ ràng), không gãy
|
|
258
|
+
* lúc import SDK — static-render web của expo-router vẫn nạp được module.
|
|
185
259
|
*/
|
|
186
260
|
export function createNativeRtmpBindings(): NativeRtmpBindings {
|
|
187
261
|
const native = require("@pickleball/rtmp-native").default;
|
|
262
|
+
const { Platform, PermissionsAndroid } = require("react-native");
|
|
188
263
|
return {
|
|
189
264
|
prepare: (options) => native.prepare(options),
|
|
265
|
+
// Android: xin quyền THUẦN, không mở camera như getUserMedia của WebRTC.
|
|
266
|
+
// Máy MediaTek (Xiaomi tanzanite) chỉ cho một camera mở cùng lúc — camera
|
|
267
|
+
// WebRTC chưa nhả (~400ms) là HaishinKit dính "Too many users (-87)" và
|
|
268
|
+
// preview đen. iOS không có giới hạn đó nên để engine fallback getUserMedia.
|
|
269
|
+
...(Platform.OS === "android"
|
|
270
|
+
? {
|
|
271
|
+
async requestPermissions() {
|
|
272
|
+
const granted = await PermissionsAndroid.requestMultiple([
|
|
273
|
+
PermissionsAndroid.PERMISSIONS.CAMERA,
|
|
274
|
+
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
|
|
275
|
+
]);
|
|
276
|
+
return (
|
|
277
|
+
granted[PermissionsAndroid.PERMISSIONS.CAMERA] ===
|
|
278
|
+
PermissionsAndroid.RESULTS.GRANTED &&
|
|
279
|
+
granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
|
|
280
|
+
PermissionsAndroid.RESULTS.GRANTED
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
: {}),
|
|
190
285
|
isLandscape() {
|
|
191
286
|
const { width, height } = require("react-native").Dimensions.get("window");
|
|
192
287
|
return width > height;
|
|
@@ -201,12 +296,22 @@ export function createNativeRtmpBindings(): NativeRtmpBindings {
|
|
|
201
296
|
setVideoBitrate: (bps) => native.setVideoBitrate(bps),
|
|
202
297
|
dispose: () => native.dispose(),
|
|
203
298
|
addStatusListener: (listener) => native.addListener("onStatusChanged", listener),
|
|
299
|
+
addBandwidthListener: (listener) => native.addListener("onBandwidth", listener),
|
|
204
300
|
};
|
|
205
301
|
}
|
|
206
302
|
|
|
207
303
|
/** roomFactory cho mode device-rtmp (thiết bị đẩy RTMP, không dùng LiveKit). */
|
|
208
|
-
export function createRtmpRoomFactory(
|
|
209
|
-
|
|
304
|
+
export function createRtmpRoomFactory(options?: {
|
|
305
|
+
/**
|
|
306
|
+
* fps capture + encode (24 tiết kiệm băng thông, 30 mặc định của native).
|
|
307
|
+
* 60 chỉ nên truyền khi `getCameraCapabilities().supportedFrameRates` có nó —
|
|
308
|
+
* native bỏ qua trong im lặng nếu ống kính đang gắn không chạy được.
|
|
309
|
+
*/
|
|
310
|
+
frameRate?: 24 | 30 | 60;
|
|
311
|
+
/** Camera mở sẵn lúc prepare — thiếu = camera sau mặc định của native. */
|
|
312
|
+
cameraId?: string;
|
|
313
|
+
}): EngineDependencies["roomFactory"] {
|
|
314
|
+
return () => new RtmpRoomAdapter(createNativeRtmpBindings(), options);
|
|
210
315
|
}
|
|
211
316
|
|
|
212
317
|
/**
|
|
@@ -264,6 +369,21 @@ export function createDefaultDeviceMetrics(): NonNullable<
|
|
|
264
369
|
/* expo-device chưa được cài */
|
|
265
370
|
}
|
|
266
371
|
|
|
372
|
+
// Nhiệt máy + nhiệt pin: chỉ có đường native (không package Expo nào đọc
|
|
373
|
+
// được). Guard require như các nguồn khác — app không cài rtmp-native thì
|
|
374
|
+
// bỏ qua hai trường này thay vì gãy cả collector.
|
|
375
|
+
try {
|
|
376
|
+
const native = require("@pickleball/rtmp-native").default;
|
|
377
|
+
const health = await native.getDeviceHealth();
|
|
378
|
+
if (typeof health?.thermal === "number") metrics.thermal = health.thermal;
|
|
379
|
+
if (typeof health?.batteryTempC === "number") {
|
|
380
|
+
// Làm tròn 1 số lẻ: pin báo theo 1/10 °C, giữ nguyên là đủ chính xác.
|
|
381
|
+
metrics.batteryTempC = Math.round(health.batteryTempC * 10) / 10;
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
notes.push(`device health: ${String(error)}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
267
387
|
try {
|
|
268
388
|
const NetInfo = require("@react-native-community/netinfo").default;
|
|
269
389
|
const status = await NetInfo.fetch();
|
|
@@ -277,6 +397,42 @@ export function createDefaultDeviceMetrics(): NonNullable<
|
|
|
277
397
|
).toUpperCase()
|
|
278
398
|
: status.type;
|
|
279
399
|
}
|
|
400
|
+
// Chi tiết mạng cho việc truy vết sự cố tại sân: máy còn bám router của
|
|
401
|
+
// giải hay đã rớt sang 4G, sóng yếu tới mức nào.
|
|
402
|
+
//
|
|
403
|
+
// CỐ Ý không đọc `details.ssid`/`bssid`: hai field đó bị khoá sau quyền
|
|
404
|
+
// VỊ TRÍ (Android cần ACCESS_FINE_LOCATION + bật GPS; iOS cần entitlement
|
|
405
|
+
// Access WiFi Information). Xin quyền vị trí cho mỗi máy đặt ở sân chỉ để
|
|
406
|
+
// hiện tên WiFi là cái giá quá đắt — IP nội bộ dưới đây phân biệt được
|
|
407
|
+
// router mà không cần quyền nào.
|
|
408
|
+
const details = status?.details as
|
|
409
|
+
| {
|
|
410
|
+
ipAddress?: unknown;
|
|
411
|
+
strength?: unknown;
|
|
412
|
+
linkSpeed?: unknown;
|
|
413
|
+
frequency?: unknown;
|
|
414
|
+
}
|
|
415
|
+
| undefined;
|
|
416
|
+
if (typeof details?.ipAddress === "string" && details.ipAddress) {
|
|
417
|
+
metrics.localIp = details.ipAddress;
|
|
418
|
+
}
|
|
419
|
+
// strength: 0-100 (%). CHỈ Android — iOS không mở cường độ sóng cho app.
|
|
420
|
+
if (typeof details?.strength === "number" && Number.isFinite(details.strength)) {
|
|
421
|
+
metrics.signalPct = Math.round(details.strength);
|
|
422
|
+
}
|
|
423
|
+
if (typeof details?.linkSpeed === "number" && Number.isFinite(details.linkSpeed)) {
|
|
424
|
+
metrics.linkMbps = Math.round(details.linkSpeed);
|
|
425
|
+
}
|
|
426
|
+
// Tần số kênh (Android) — phân biệt 2.4/5 GHz không cần quyền vị trí.
|
|
427
|
+
// Chỉ đi vào telemetry PHIÊN (dataJson tự do); heartbeat thiết bị lọc
|
|
428
|
+
// theo METRIC_FIELDS nên không đụng validator server.
|
|
429
|
+
if (
|
|
430
|
+
typeof details?.frequency === "number" &&
|
|
431
|
+
Number.isFinite(details.frequency) &&
|
|
432
|
+
details.frequency > 0
|
|
433
|
+
) {
|
|
434
|
+
metrics.freqMhz = Math.round(details.frequency);
|
|
435
|
+
}
|
|
280
436
|
} catch {
|
|
281
437
|
/* netinfo không khả dụng */
|
|
282
438
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -4,12 +4,19 @@ import {
|
|
|
4
4
|
withAndroidManifest,
|
|
5
5
|
withDangerousMod,
|
|
6
6
|
withInfoPlist,
|
|
7
|
-
withPlugins,
|
|
8
7
|
type ConfigPlugin,
|
|
9
8
|
} from "expo/config-plugins";
|
|
10
9
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
11
10
|
import { join } from "node:path";
|
|
12
|
-
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Version của SCHEMA plugin — tách khỏi version package có chủ ý. Giá trị này
|
|
14
|
+
* được inline vào dist/plugin.cjs, mà @expo/fingerprint băm file đó làm
|
|
15
|
+
* runtimeVersion của app fleet: gắn theo version package là MỖI lần bump SDK
|
|
16
|
+
* (kể cả patch JS-only) đổi fingerprint và toàn bộ máy ngoài sân mất OTA. Chỉ
|
|
17
|
+
* nâng khi plugin đổi hành vi native (permission, mod).
|
|
18
|
+
*/
|
|
19
|
+
export const PLUGIN_SCHEMA_VERSION = "1";
|
|
13
20
|
|
|
14
21
|
export interface PickleballLivePluginProps {
|
|
15
22
|
cameraPermission?: string;
|
|
@@ -24,11 +31,17 @@ export interface PickleballLivePluginProps {
|
|
|
24
31
|
* - voice: false → hiện chưa gate gì trong plugin (mic thuộc livestream);
|
|
25
32
|
* nhận trước để tương thích về sau. Muốn loại hẳn native khỏi binary, thêm
|
|
26
33
|
* expo.autolinking.exclude trong package.json của app (xem README).
|
|
34
|
+
* - livekit: KHÔNG còn tác dụng từ 1.0 (SDK RTMP-only, không compose plugin
|
|
35
|
+
* LiveKit nữa). Vẫn nhận để app.config cũ của fleet không phải sửa — sửa
|
|
36
|
+
* là đổi fingerprint OTA.
|
|
27
37
|
*/
|
|
28
38
|
modules?: {
|
|
29
39
|
remote?: boolean;
|
|
30
40
|
voice?: boolean;
|
|
41
|
+
/** @deprecated bị bỏ qua từ 1.0 */
|
|
42
|
+
livekit?: boolean;
|
|
31
43
|
};
|
|
44
|
+
/** @deprecated bị bỏ qua từ 1.0 (SDK không còn LiveKit). */
|
|
32
45
|
liveKit?: {
|
|
33
46
|
android?: { audioType?: string; enableScreenShareService?: boolean };
|
|
34
47
|
ios?: { enableMultitaskingCameraAccess?: boolean };
|
|
@@ -38,6 +51,9 @@ export interface PickleballLivePluginProps {
|
|
|
38
51
|
const CORE_ANDROID_PERMISSIONS = [
|
|
39
52
|
"android.permission.ACCESS_NETWORK_STATE",
|
|
40
53
|
"android.permission.CAMERA",
|
|
54
|
+
"android.permission.FOREGROUND_SERVICE",
|
|
55
|
+
"android.permission.FOREGROUND_SERVICE_CAMERA",
|
|
56
|
+
"android.permission.FOREGROUND_SERVICE_MICROPHONE",
|
|
41
57
|
"android.permission.INTERNET",
|
|
42
58
|
"android.permission.MODIFY_AUDIO_SETTINGS",
|
|
43
59
|
"android.permission.RECORD_AUDIO",
|
|
@@ -98,9 +114,6 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
|
|
|
98
114
|
? [...CORE_ANDROID_PERMISSIONS, ...REMOTE_ANDROID_PERMISSIONS]
|
|
99
115
|
: [...CORE_ANDROID_PERMISSIONS];
|
|
100
116
|
|
|
101
|
-
config = withPlugins(config, [
|
|
102
|
-
["@livekit/react-native-expo-plugin", props?.liveKit],
|
|
103
|
-
]);
|
|
104
117
|
config.ios = { ...config.ios, bitcode: false };
|
|
105
118
|
config = withInfoPlist(config, (mod) => {
|
|
106
119
|
mod.modResults.NSCameraUsageDescription = cameraPermission;
|
|
@@ -162,5 +175,5 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
|
|
|
162
175
|
export default createRunOncePlugin(
|
|
163
176
|
withPickleballLive,
|
|
164
177
|
"@pickleball/expo-sdk",
|
|
165
|
-
|
|
178
|
+
PLUGIN_SCHEMA_VERSION,
|
|
166
179
|
);
|
package/src/provider.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
2
2
|
import { AppState } from "react-native";
|
|
3
3
|
import type { BootstrapInput, LivestreamSessionProvider, StartLivestreamInput, EndReason } from "./contracts";
|
|
4
4
|
import { ExpoSdkConfigurationError, type PickleballExpoError } from "./errors";
|
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
type SdkNetwork,
|
|
11
11
|
type SdkRuntime,
|
|
12
12
|
type SdkStorage,
|
|
13
|
+
type SessionCommandChannel,
|
|
14
|
+
type SessionCommandSubscription,
|
|
13
15
|
type TelemetrySink,
|
|
14
16
|
} from "./engine";
|
|
15
17
|
import {
|
|
18
|
+
createDefaultCommandChannel,
|
|
16
19
|
createDefaultDeviceMetrics,
|
|
17
20
|
createDefaultNetwork,
|
|
18
21
|
createDefaultRoomFactory,
|
|
@@ -64,10 +67,33 @@ export interface PickleballLiveProviderProps {
|
|
|
64
67
|
children?: ReactNode;
|
|
65
68
|
sessionProvider: LivestreamSessionProvider;
|
|
66
69
|
bootstrapInput: BootstrapInput;
|
|
70
|
+
/**
|
|
71
|
+
* Phiên phải SỐNG TIẾP khi app biến mất giữa chừng (sụp nguồn, bị vuốt tắt,
|
|
72
|
+
* component remount), để lần mở sau nhận lại đúng phiên cũ — giữ nguyên link
|
|
73
|
+
* xem và VOD liền một mạch.
|
|
74
|
+
*
|
|
75
|
+
* Mặc định false = hành vi cũ: mở app lại là dọn sạch phiên cũ. App gác sân bật
|
|
76
|
+
* cờ này khi máy đang được gán sân; máy không gán sân vẫn dọn như cũ để không
|
|
77
|
+
* để lại phiên treo trong các ca ngoài giải.
|
|
78
|
+
*
|
|
79
|
+
* Nhận boolean HOẶC hàm (được phép async). App nên truyền HÀM ĐỌC THẲNG ĐĨA:
|
|
80
|
+
* engine hỏi cờ này đúng lúc khởi động, thường TRƯỚC khi state React kịp
|
|
81
|
+
* restore — truyền boolean suy từ state là dính race keep=false oan (đã dính
|
|
82
|
+
* thật 18/08/2026, sinh phiên chờ thứ hai đè phiên live cũ).
|
|
83
|
+
*/
|
|
84
|
+
keepSessionOnRestart?: boolean | (() => boolean | Promise<boolean>);
|
|
67
85
|
runtime?: SdkRuntime;
|
|
68
86
|
storage?: SdkStorage;
|
|
69
87
|
network?: SdkNetwork;
|
|
70
88
|
telemetry?: TelemetrySink;
|
|
89
|
+
/** Kênh poll lệnh cho mode device-rtmp — mặc định suy từ telemetry endpoint. */
|
|
90
|
+
commandChannel?: SessionCommandChannel;
|
|
91
|
+
/**
|
|
92
|
+
* Kênh nhận lệnh qua websocket (first-party nối thẳng Convex). Có thì lệnh về
|
|
93
|
+
* tức thì và poll tụt về nhịp reconcile chậm — cắt phần lớn function call của
|
|
94
|
+
* đường poll. Thiếu (máy đối tác qua proxy) thì chỉ dùng poll HTTP như cũ.
|
|
95
|
+
*/
|
|
96
|
+
commandSubscription?: SessionCommandSubscription;
|
|
71
97
|
roomFactory?: () => RoomAdapter;
|
|
72
98
|
deviceMetrics?: EngineDependencies["deviceMetrics"];
|
|
73
99
|
theme?: Partial<LivestreamTheme>;
|
|
@@ -121,6 +147,31 @@ const LivestreamContext = React.createContext<LivestreamContextValue | null>(nul
|
|
|
121
147
|
|
|
122
148
|
export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
|
|
123
149
|
const engineRef = useRef<LivestreamEngine | null>(null);
|
|
150
|
+
const keepSessionRef = useRef(props.keepSessionOnRestart);
|
|
151
|
+
keepSessionRef.current = props.keepSessionOnRestart;
|
|
152
|
+
// dispose() phải quyết NGAY (sync) có giữ phiên hay không, còn prop dạng hàm
|
|
153
|
+
// thì async — resolve sẵn vào ref này. Khi prop là hàm mà chưa có kết quả,
|
|
154
|
+
// nghiêng về GIỮ: dọn oan là mất phiên vĩnh viễn (link đổi, VOD tách đôi),
|
|
155
|
+
// giữ oan thì lưới server (1 giờ live / 30 phút standby) dọn hộ.
|
|
156
|
+
const resolvedKeepRef = useRef(typeof props.keepSessionOnRestart === "function");
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const value = props.keepSessionOnRestart;
|
|
159
|
+
if (typeof value !== "function") {
|
|
160
|
+
resolvedKeepRef.current = value === true;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
resolvedKeepRef.current = true;
|
|
164
|
+
let cancelled = false;
|
|
165
|
+
void Promise.resolve()
|
|
166
|
+
.then(() => value())
|
|
167
|
+
.then((keep) => {
|
|
168
|
+
if (!cancelled) resolvedKeepRef.current = keep === true;
|
|
169
|
+
})
|
|
170
|
+
.catch(() => undefined);
|
|
171
|
+
return () => {
|
|
172
|
+
cancelled = true;
|
|
173
|
+
};
|
|
174
|
+
}, [props.keepSessionOnRestart]);
|
|
124
175
|
const hostRef = useRef<LivestreamHostContextValue | null>(null);
|
|
125
176
|
const effectGenerationRef = useRef(0);
|
|
126
177
|
if (!engineRef.current) {
|
|
@@ -132,8 +183,19 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
|
|
|
132
183
|
storage: props.storage ?? createDefaultStorage(),
|
|
133
184
|
network: props.network ?? createDefaultNetwork(),
|
|
134
185
|
telemetry: props.telemetry ?? createDefaultTelemetry(),
|
|
186
|
+
commandChannel: props.commandChannel ?? createDefaultCommandChannel(),
|
|
187
|
+
...(props.commandSubscription
|
|
188
|
+
? { commandSubscription: props.commandSubscription }
|
|
189
|
+
: {}),
|
|
135
190
|
deviceMetrics,
|
|
136
191
|
roomFactory: props.roomFactory ?? createDefaultRoomFactory(),
|
|
192
|
+
// Đọc qua ref để engine luôn thấy giá trị MỚI NHẤT: engine chỉ dựng một lần
|
|
193
|
+
// (engineRef), còn prop thì đổi theo việc máy được gán/gỡ sân. Prop dạng
|
|
194
|
+
// hàm được gọi xuyên qua (engine tự await) — đường cho app đọc thẳng đĩa.
|
|
195
|
+
keepSessionOnRestart: () => {
|
|
196
|
+
const value = keepSessionRef.current;
|
|
197
|
+
return typeof value === "function" ? value() : value === true;
|
|
198
|
+
},
|
|
137
199
|
};
|
|
138
200
|
engineRef.current = new LivestreamEngine(dependencies);
|
|
139
201
|
hostRef.current = {
|
|
@@ -161,7 +223,7 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
|
|
|
161
223
|
subscription.remove();
|
|
162
224
|
queueMicrotask(() => {
|
|
163
225
|
if (effectGenerationRef.current === effectGeneration) {
|
|
164
|
-
void engine.dispose();
|
|
226
|
+
void engine.dispose({ keepSession: resolvedKeepRef.current });
|
|
165
227
|
}
|
|
166
228
|
});
|
|
167
229
|
};
|
|
@@ -171,22 +233,36 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
|
|
|
171
233
|
if (snapshot.error) props.onError?.(snapshot.error);
|
|
172
234
|
}, [props.onError, snapshot.error]);
|
|
173
235
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
engine.
|
|
188
|
-
|
|
189
|
-
|
|
236
|
+
// Hành động ổn định theo engine (không dựng lại mỗi render): các effect phía
|
|
237
|
+
// trên (AutoResume, useStandbySession, app đối tác) đặt chúng vào dependency
|
|
238
|
+
// — dựng lại mỗi render là effect huỷ/chạy lại theo nhịp render, timer thử
|
|
239
|
+
// lại không bao giờ kịp nổ.
|
|
240
|
+
const actions = useMemo(
|
|
241
|
+
() => ({
|
|
242
|
+
prepare: () => engine.prepare(),
|
|
243
|
+
start: (input: StartLivestreamInput, options?: { autoPublish?: boolean }) =>
|
|
244
|
+
engine.start(input, options),
|
|
245
|
+
startPublishing: () => engine.startPublishing(),
|
|
246
|
+
stopPublishing: () => engine.stopPublishing(),
|
|
247
|
+
stop: (reason?: EndReason) => engine.stop(reason),
|
|
248
|
+
retry: () => engine.retry(),
|
|
249
|
+
switchCamera: () => engine.switchCamera(),
|
|
250
|
+
setMicrophoneEnabled: (enabled: boolean) => engine.setMicrophoneEnabled(enabled),
|
|
251
|
+
setCaptureOrientation: (orientation: "portrait" | "landscape") =>
|
|
252
|
+
engine.setCaptureOrientation(orientation),
|
|
253
|
+
selectCamera: (deviceId: string) => engine.selectCamera(deviceId),
|
|
254
|
+
}),
|
|
255
|
+
[engine],
|
|
256
|
+
);
|
|
257
|
+
const locale = props.locale ?? "vi";
|
|
258
|
+
const theme = useMemo(
|
|
259
|
+
() => ({ ...defaultLivestreamTheme, ...props.theme }),
|
|
260
|
+
[props.theme],
|
|
261
|
+
);
|
|
262
|
+
const value = useMemo<LivestreamContextValue>(
|
|
263
|
+
() => ({ ...snapshot, locale, theme, ...actions }),
|
|
264
|
+
[snapshot, locale, theme, actions],
|
|
265
|
+
);
|
|
190
266
|
return (
|
|
191
267
|
<LivestreamHostContext.Provider value={hostRef.current}>
|
|
192
268
|
<LivestreamContext.Provider value={value}>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kênh lệnh realtime qua websocket Convex cho app dùng camera grant.
|
|
3
|
+
*
|
|
4
|
+
* Grant có `convexUrl` (origin .convex.cloud) → truyền
|
|
5
|
+
* `commandSubscription={createGrantCommandSubscription(() => grant?.convexUrl)}`
|
|
6
|
+
* vào PickleballLiveProvider: lệnh start/stop/end về tức thì và poll HTTP tụt
|
|
7
|
+
* về nhịp đối chiếu 30s (thay vì 2s/4s — ~3.600 call/giờ/máy).
|
|
8
|
+
*
|
|
9
|
+
* Subpath riêng vì kéo theo `convex`; root entry giữ gọn. `./device-agent` vẫn
|
|
10
|
+
* re-export cùng hàm cho app fleet.
|
|
11
|
+
*/
|
|
12
|
+
import type { SdkTelemetryCredentials, SessionCommandRecord } from "../contracts";
|
|
13
|
+
import {
|
|
14
|
+
createConvexCommandSubscription,
|
|
15
|
+
createConvexDeviceRealtime,
|
|
16
|
+
type ConvexDeviceRealtime,
|
|
17
|
+
} from "../device-agent/convex-command-subscription";
|
|
18
|
+
import type { SessionCommandSubscription } from "../engine";
|
|
19
|
+
|
|
20
|
+
export { createConvexCommandSubscription, createConvexDeviceRealtime, type ConvexDeviceRealtime };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Subscription cho app dùng camera grant: URL Convex nằm TRONG grant
|
|
24
|
+
* (`grant.convexUrl`) nên chỉ biết sau khi start — đọc URL lúc engine subscribe
|
|
25
|
+
* (ngay sau khi vào phiên), dựng MỘT client mỗi origin và tái dùng qua các
|
|
26
|
+
* phiên. Không có URL (deployment không lộ origin chuẩn) → không subscribe,
|
|
27
|
+
* engine giữ poll HTTP như cũ.
|
|
28
|
+
*
|
|
29
|
+
* Dùng với createCameraGrantProvider: `() => provider.getActiveGrant()?.convexUrl`.
|
|
30
|
+
*/
|
|
31
|
+
export function createGrantCommandSubscription(
|
|
32
|
+
getConvexUrl: () => string | null | undefined,
|
|
33
|
+
): SessionCommandSubscription {
|
|
34
|
+
const clients = new Map<string, ConvexDeviceRealtime>();
|
|
35
|
+
return {
|
|
36
|
+
subscribe(
|
|
37
|
+
credentials: SdkTelemetryCredentials,
|
|
38
|
+
sessionId: string,
|
|
39
|
+
onCommands: (commands: SessionCommandRecord[]) => void,
|
|
40
|
+
onError?: (error: Error) => void,
|
|
41
|
+
) {
|
|
42
|
+
const url = getConvexUrl();
|
|
43
|
+
if (!url) return () => undefined;
|
|
44
|
+
let realtime = clients.get(url);
|
|
45
|
+
if (!realtime) {
|
|
46
|
+
realtime = createConvexDeviceRealtime(url);
|
|
47
|
+
clients.set(url, realtime);
|
|
48
|
+
}
|
|
49
|
+
return realtime.commandSubscription.subscribe(credentials, sessionId, onCommands, onError);
|
|
50
|
+
},
|
|
51
|
+
dispose() {
|
|
52
|
+
for (const realtime of clients.values()) realtime.dispose();
|
|
53
|
+
clients.clear();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|