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