@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
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CameraGrant,
|
|
3
|
+
EndLivestreamInput,
|
|
4
|
+
SdkBootstrap,
|
|
5
|
+
SdkRemoteConfig,
|
|
6
|
+
SdkSessionGrant,
|
|
7
|
+
SdkSessionRefresh,
|
|
8
|
+
StartLivestreamInput,
|
|
9
|
+
} from "./contracts";
|
|
10
|
+
import type { SdkStorage } from "./engine";
|
|
11
|
+
import {
|
|
12
|
+
InvalidResponseError,
|
|
13
|
+
SessionConflictError,
|
|
14
|
+
TokenExpiredError,
|
|
15
|
+
errorFromCode,
|
|
16
|
+
} from "./errors";
|
|
17
|
+
import { createDefaultStorage } from "./native-runtime";
|
|
18
|
+
import { classifyResumeStatus, resumeGrantMatches } from "./resume-policy";
|
|
19
|
+
import type { ResumableSessionProvider, ResumeAttempt } from "./resume";
|
|
20
|
+
import {
|
|
21
|
+
createJsonPoster,
|
|
22
|
+
httpStatusOf,
|
|
23
|
+
isCameraGrant,
|
|
24
|
+
isRecord,
|
|
25
|
+
isSessionStatus,
|
|
26
|
+
sessionApiBaseFromTelemetryEndpoint,
|
|
27
|
+
type Fetch,
|
|
28
|
+
} from "./session-grant";
|
|
29
|
+
import { PICKLEBALL_EXPO_SDK_VERSION } from "./version";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Session provider cho ĐIỆN THOẠI ĐỐI TÁC (camera grant, ADR-0002).
|
|
33
|
+
*
|
|
34
|
+
* App đối tác ──(auth riêng)──► Backend đối tác ──khoá API (chỉ server)──► PickLive REST v1
|
|
35
|
+
* App đối tác ◄───────── CameraGrant nguyên trạng ◄──────────
|
|
36
|
+
* SDK ──Bearer grant.telemetry.token──► PickLive /api/v2/camera/sessions/{id}/*
|
|
37
|
+
*
|
|
38
|
+
* App KHÔNG cầm API key, KHÔNG cấu hình URL PickLive: mọi endpoint suy từ
|
|
39
|
+
* `telemetry.endpoint` trong grant. Backend đối tác chỉ có đúng một việc — cấp grant.
|
|
40
|
+
*
|
|
41
|
+
* Handle phiên `{sessionId, base, token}` được LƯU trong sandbox app (expo-sqlite
|
|
42
|
+
* localStorage) duy nhất để nhận lại phiên sau khi app bị kill; token khoá theo
|
|
43
|
+
* một phiên và tự hết hạn. Đây là thay đổi so với lời hứa "token chỉ ở bộ nhớ"
|
|
44
|
+
* của mô hình proxy cũ — chủ ý, để resume hoạt động.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export const CAMERA_GRANT_STORAGE_KEY = "@pickleball/expo-sdk/camera-grant";
|
|
48
|
+
|
|
49
|
+
/** Config local cho bước prepare (engine chỉ dùng enabled/minSdkVersion/videoQuality ở đây). */
|
|
50
|
+
export const DEFAULT_LOCAL_CONFIG: SdkRemoteConfig = {
|
|
51
|
+
enabled: true,
|
|
52
|
+
protocolVersion: 1,
|
|
53
|
+
minSdkVersion: "0.0.0",
|
|
54
|
+
recommendedSdkVersion: PICKLEBALL_EXPO_SDK_VERSION,
|
|
55
|
+
videoQuality: 720,
|
|
56
|
+
reconnectTimeoutMs: 30_000,
|
|
57
|
+
backgroundGraceMs: 90_000,
|
|
58
|
+
telemetryIntervalMs: 60_000,
|
|
59
|
+
maxSessionDurationMs: 6 * 60 * 60_000,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type CameraGrantPhase = "start" | "refresh" | "resume";
|
|
63
|
+
|
|
64
|
+
export interface CameraGrantProviderOptions {
|
|
65
|
+
/**
|
|
66
|
+
* App đối tác gọi backend đối tác → backend gọi PickLive → trả grant nguyên
|
|
67
|
+
* trạng. Nhận StartLivestreamInput của engine (kèm `standby`, `quality`) để
|
|
68
|
+
* backend chuyển tiếp title/quality nếu muốn.
|
|
69
|
+
*/
|
|
70
|
+
requestGrant(input: StartLivestreamInput): Promise<unknown>;
|
|
71
|
+
storage?: SdkStorage;
|
|
72
|
+
storageKey?: string;
|
|
73
|
+
fetch?: Fetch;
|
|
74
|
+
timeoutMs?: number;
|
|
75
|
+
/** Ghi đè config local của bước prepare (VD videoQuality theo cài đặt máy). */
|
|
76
|
+
localConfig?: Partial<SdkRemoteConfig>;
|
|
77
|
+
onGrant?(grant: CameraGrant, phase: CameraGrantPhase): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CameraGrantHandle {
|
|
81
|
+
sessionId: string;
|
|
82
|
+
base: string;
|
|
83
|
+
token: string;
|
|
84
|
+
tokenExpiresAt: number;
|
|
85
|
+
quality?: 720 | 1080;
|
|
86
|
+
savedAt: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CameraGrantProvider extends ResumableSessionProvider {
|
|
90
|
+
/** Grant đang cầm (null khi chưa có phiên). */
|
|
91
|
+
getActiveGrant(): CameraGrant | null;
|
|
92
|
+
subscribe(listener: () => void): () => void;
|
|
93
|
+
/** Có handle phiên trên đĩa → truyền vào `keepSessionOnRestart` của provider. */
|
|
94
|
+
hasResumableSession(): Promise<boolean>;
|
|
95
|
+
/** Quên phiên (logout/đổi user). Backend đối tác nên gọi end phiên đó. */
|
|
96
|
+
clearStoredSession(): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseHandle(raw: string | null): CameraGrantHandle | null {
|
|
100
|
+
if (!raw) return null;
|
|
101
|
+
try {
|
|
102
|
+
const value = JSON.parse(raw) as unknown;
|
|
103
|
+
if (
|
|
104
|
+
!isRecord(value) ||
|
|
105
|
+
typeof value.sessionId !== "string" ||
|
|
106
|
+
typeof value.base !== "string" ||
|
|
107
|
+
typeof value.token !== "string" ||
|
|
108
|
+
typeof value.tokenExpiresAt !== "number"
|
|
109
|
+
) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
sessionId: value.sessionId,
|
|
114
|
+
base: value.base,
|
|
115
|
+
token: value.token,
|
|
116
|
+
tokenExpiresAt: value.tokenExpiresAt,
|
|
117
|
+
...(value.quality === 720 || value.quality === 1080 ? { quality: value.quality } : {}),
|
|
118
|
+
savedAt: typeof value.savedAt === "number" ? value.savedAt : 0,
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function withStatus(error: unknown): unknown {
|
|
126
|
+
// 401 từ namespace camera = grant hết hạn/không hợp lệ → engine đi vòng refresh.
|
|
127
|
+
if (httpStatusOf(error) === 401) {
|
|
128
|
+
return Object.assign(new TokenExpiredError(), { httpStatus: 401 });
|
|
129
|
+
}
|
|
130
|
+
return error;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createCameraGrantProvider(
|
|
134
|
+
options: CameraGrantProviderOptions,
|
|
135
|
+
): CameraGrantProvider {
|
|
136
|
+
const storage = options.storage ?? createDefaultStorage();
|
|
137
|
+
const storageKey = options.storageKey ?? CAMERA_GRANT_STORAGE_KEY;
|
|
138
|
+
const post = createJsonPoster({ fetch: options.fetch, timeoutMs: options.timeoutMs });
|
|
139
|
+
const listeners = new Set<() => void>();
|
|
140
|
+
|
|
141
|
+
let handle: CameraGrantHandle | null = null;
|
|
142
|
+
let handleLoaded: Promise<void> | null = null;
|
|
143
|
+
let activeGrant: CameraGrant | null = null;
|
|
144
|
+
let pendingResume: SdkSessionGrant | null = null;
|
|
145
|
+
|
|
146
|
+
const emit = () => {
|
|
147
|
+
for (const listener of listeners) listener();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
async function loadHandle() {
|
|
151
|
+
if (!handleLoaded) {
|
|
152
|
+
handleLoaded = storage
|
|
153
|
+
.getItem(storageKey)
|
|
154
|
+
.then((raw) => {
|
|
155
|
+
if (handle === null) handle = parseHandle(raw);
|
|
156
|
+
})
|
|
157
|
+
.catch(() => undefined);
|
|
158
|
+
}
|
|
159
|
+
await handleLoaded;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function saveHandle(next: CameraGrantHandle | null) {
|
|
163
|
+
handle = next;
|
|
164
|
+
try {
|
|
165
|
+
if (next) await storage.setItem(storageKey, JSON.stringify(next));
|
|
166
|
+
else await storage.removeItem(storageKey);
|
|
167
|
+
} catch {
|
|
168
|
+
// Đĩa hỏng thì mất resume, không được chặn phiên đang chạy.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function setActive(grant: CameraGrant | null) {
|
|
173
|
+
activeGrant = grant;
|
|
174
|
+
emit();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function adopt(grant: CameraGrant, quality: 720 | 1080 | undefined) {
|
|
178
|
+
const base = sessionApiBaseFromTelemetryEndpoint(grant.telemetry.endpoint);
|
|
179
|
+
await saveHandle({
|
|
180
|
+
sessionId: grant.sessionId,
|
|
181
|
+
base,
|
|
182
|
+
token: grant.telemetry.token,
|
|
183
|
+
tokenExpiresAt: grant.telemetry.expiresAt,
|
|
184
|
+
...(quality !== undefined ? { quality } : handle?.quality !== undefined ? { quality: handle.quality } : {}),
|
|
185
|
+
savedAt: Date.now(),
|
|
186
|
+
});
|
|
187
|
+
setActive(grant);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function requireHandle(sessionId: string): CameraGrantHandle {
|
|
191
|
+
if (!handle || handle.sessionId !== sessionId) {
|
|
192
|
+
// 404 để engine dừng quay số thay vì thử lại vô ích (engine bám mã HTTP).
|
|
193
|
+
throw Object.assign(
|
|
194
|
+
new SessionConflictError("No camera grant is held for this session"),
|
|
195
|
+
{ httpStatus: 404 },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return handle;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function bearer(current: CameraGrantHandle) {
|
|
202
|
+
return { Authorization: `Bearer ${current.token}` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function call(current: CameraGrantHandle, action: string, body: unknown) {
|
|
206
|
+
try {
|
|
207
|
+
return await post(`${current.base}/${action}`, body, bearer(current));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw withStatus(error);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const provider: CameraGrantProvider = {
|
|
214
|
+
async bootstrap(): Promise<SdkBootstrap> {
|
|
215
|
+
await loadHandle();
|
|
216
|
+
return {
|
|
217
|
+
config: { ...DEFAULT_LOCAL_CONFIG, ...options.localConfig },
|
|
218
|
+
serverTime: Date.now(),
|
|
219
|
+
rolloutEnabled: true,
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
async start(input: StartLivestreamInput): Promise<SdkSessionGrant> {
|
|
224
|
+
await loadHandle();
|
|
225
|
+
const resumeGrant = pendingResume;
|
|
226
|
+
if (resumeGrant && resumeGrantMatches(input.externalSessionId, resumeGrant.sessionId)) {
|
|
227
|
+
pendingResume = null;
|
|
228
|
+
return resumeGrant;
|
|
229
|
+
}
|
|
230
|
+
const raw = await options.requestGrant(input);
|
|
231
|
+
if (!isCameraGrant(raw)) {
|
|
232
|
+
throw new InvalidResponseError("The partner backend returned an invalid camera grant");
|
|
233
|
+
}
|
|
234
|
+
await adopt(raw, input.quality);
|
|
235
|
+
// engine.start({autoPublish:true}) KHÔNG gọi provider.publish (chỉ
|
|
236
|
+
// startPublishing từ standby mới gọi) — server phải được lật live TRƯỚC khi
|
|
237
|
+
// app đẩy RTMP, giống thứ tự start không-standby của proxy cũ.
|
|
238
|
+
if (input.standby !== true && raw.status !== "live") {
|
|
239
|
+
const current = requireHandle(raw.sessionId);
|
|
240
|
+
try {
|
|
241
|
+
const data = await call(current, "publish", {
|
|
242
|
+
sessionId: raw.sessionId,
|
|
243
|
+
...(input.quality !== undefined ? { quality: input.quality } : {}),
|
|
244
|
+
});
|
|
245
|
+
if (!isSessionStatus(data)) throw new InvalidResponseError();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// Engine chỉ nhận grant SAU khi start() resolve, nên publish hỏng ở đây
|
|
248
|
+
// là không ai end phiên này giúp: phiên treo trên server (đối tác đã
|
|
249
|
+
// tạo) và handle trên đĩa báo "còn phiên để nhận lại" cho một phiên
|
|
250
|
+
// chưa từng phát. Kết thúc best-effort rồi dọn handle; app xin grant
|
|
251
|
+
// mới khi user bấm lại.
|
|
252
|
+
await call(current, "end", { sessionId: raw.sessionId, reason: "error" }).catch(
|
|
253
|
+
() => undefined,
|
|
254
|
+
);
|
|
255
|
+
await saveHandle(null);
|
|
256
|
+
setActive(null);
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
setActive({ ...raw, status: "live" });
|
|
260
|
+
}
|
|
261
|
+
options.onGrant?.(activeGrant ?? raw, "start");
|
|
262
|
+
return raw;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async refresh(sessionId: string): Promise<SdkSessionRefresh> {
|
|
266
|
+
await loadHandle();
|
|
267
|
+
const current = requireHandle(sessionId);
|
|
268
|
+
const data = await call(current, "refresh", {});
|
|
269
|
+
if (!isCameraGrant(data)) throw new InvalidResponseError();
|
|
270
|
+
// Ghi đĩa TRƯỚC khi trả: nhịp poll kế đọc token mới từ grant của engine, mà
|
|
271
|
+
// engine chỉ cập nhật sau khi promise này resolve.
|
|
272
|
+
await adopt(data, current.quality);
|
|
273
|
+
options.onGrant?.(data, "refresh");
|
|
274
|
+
return {
|
|
275
|
+
participantToken: data.participantToken,
|
|
276
|
+
tokenExpiresAt: data.tokenExpiresAt,
|
|
277
|
+
telemetry: data.telemetry,
|
|
278
|
+
config: data.config,
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async end(input: EndLivestreamInput): Promise<void> {
|
|
283
|
+
await loadHandle();
|
|
284
|
+
if (!handle || handle.sessionId !== input.sessionId) {
|
|
285
|
+
// Không cầm grant của phiên đó → coi như đã xong. Ném ở đây là khoá
|
|
286
|
+
// engine vĩnh viễn ở "Another livestream session is still active".
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const current = handle;
|
|
290
|
+
// Bearer đã hết hạn thì KHÔNG có request nào của mình được server nhận
|
|
291
|
+
// nữa (401 mãi) — và engine chỉ coi 404 là "phiên biến mất", còn lại giữ
|
|
292
|
+
// dấu pending-end và ném "The previous livestream is still pending end"
|
|
293
|
+
// ở mọi lượt start sau: máy không bao giờ phát lại được. Token chết là
|
|
294
|
+
// phiên đó không còn của mình: dọn handle, để lưới server dọn phiên.
|
|
295
|
+
if (current.tokenExpiresAt <= Date.now()) {
|
|
296
|
+
await saveHandle(null);
|
|
297
|
+
setActive(null);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
await call(current, "end", { sessionId: input.sessionId, reason: input.reason });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const status = httpStatusOf(error);
|
|
304
|
+
// 404/410: phiên biến mất; 409: đã kết thúc hoặc grant bị thay — ở cả ba ca
|
|
305
|
+
// phiên này không còn là của mình, dọn handle để app arm phiên mới được.
|
|
306
|
+
if (status !== 404 && status !== 409 && status !== 410) throw error;
|
|
307
|
+
}
|
|
308
|
+
await saveHandle(null);
|
|
309
|
+
setActive(null);
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async publish(sessionId: string): Promise<void> {
|
|
313
|
+
await loadHandle();
|
|
314
|
+
const current = requireHandle(sessionId);
|
|
315
|
+
const data = await call(current, "publish", {
|
|
316
|
+
sessionId,
|
|
317
|
+
...(current.quality !== undefined ? { quality: current.quality } : {}),
|
|
318
|
+
});
|
|
319
|
+
if (!isSessionStatus(data)) throw new InvalidResponseError();
|
|
320
|
+
if (activeGrant) setActive({ ...activeGrant, status: "live" });
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
async unpublish(sessionId: string): Promise<void> {
|
|
324
|
+
await loadHandle();
|
|
325
|
+
const current = requireHandle(sessionId);
|
|
326
|
+
const data = await call(current, "unpublish", { sessionId });
|
|
327
|
+
if (!isSessionStatus(data)) throw new InvalidResponseError();
|
|
328
|
+
if (activeGrant) setActive({ ...activeGrant, status: "scheduled" });
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
adoptResumeGrant(grant: SdkSessionGrant) {
|
|
332
|
+
pendingResume = grant;
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
async resume(sessionId: string): Promise<ResumeAttempt> {
|
|
336
|
+
await loadHandle();
|
|
337
|
+
if (!handle || handle.sessionId !== sessionId) return { kind: "gone" };
|
|
338
|
+
const current = handle;
|
|
339
|
+
// Token hết hạn không bao giờ xin được token kế: "retry" ở đây là vòng
|
|
340
|
+
// lặp vô hạn chặn luôn cả việc arm phiên mới (resumeBlocksArm). Phiên đó
|
|
341
|
+
// đã bị lưới server dọn từ lâu (no-publisher 1h) — coi là gone.
|
|
342
|
+
if (current.tokenExpiresAt <= Date.now()) {
|
|
343
|
+
await saveHandle(null);
|
|
344
|
+
return { kind: "gone" };
|
|
345
|
+
}
|
|
346
|
+
let data: unknown;
|
|
347
|
+
try {
|
|
348
|
+
data = await post(`${current.base}/refresh`, {}, bearer(current));
|
|
349
|
+
} catch (error) {
|
|
350
|
+
const status = httpStatusOf(error);
|
|
351
|
+
if (status === undefined) return { kind: "retry" };
|
|
352
|
+
const verdict = classifyResumeStatus(status);
|
|
353
|
+
return { kind: verdict === "ok" ? "retry" : verdict };
|
|
354
|
+
}
|
|
355
|
+
if (!isCameraGrant(data)) return { kind: "retry" };
|
|
356
|
+
await adopt(data, current.quality);
|
|
357
|
+
options.onGrant?.(data, "resume");
|
|
358
|
+
return { kind: "grant", grant: data, live: data.status === "live" };
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
getActiveGrant: () => activeGrant,
|
|
362
|
+
subscribe(listener) {
|
|
363
|
+
listeners.add(listener);
|
|
364
|
+
return () => {
|
|
365
|
+
listeners.delete(listener);
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
async hasResumableSession() {
|
|
369
|
+
await loadHandle();
|
|
370
|
+
return handle !== null;
|
|
371
|
+
},
|
|
372
|
+
async clearStoredSession() {
|
|
373
|
+
pendingResume = null;
|
|
374
|
+
await saveHandle(null);
|
|
375
|
+
setActive(null);
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
return provider;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Grant hiện hành của provider, theo dõi bằng useSyncExternalStore. */
|
|
382
|
+
export function useCameraGrant(provider: CameraGrantProvider): CameraGrant | null {
|
|
383
|
+
// Import lười để module này không kéo React vào môi trường không có React.
|
|
384
|
+
const React = require("react") as typeof import("react");
|
|
385
|
+
return React.useSyncExternalStore(
|
|
386
|
+
provider.subscribe,
|
|
387
|
+
provider.getActiveGrant,
|
|
388
|
+
provider.getActiveGrant,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export { errorFromCode as cameraGrantErrorFromCode };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import type { RoomAdapter, SdkStorage } from "./engine";
|
|
3
|
+
import { createDefaultStorage, createRtmpRoomFactory } from "./native-runtime";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cấu hình camera LOCAL — không phụ thuộc server, sống qua OTA (chuyển từ
|
|
7
|
+
* apps/device-streamer/src/local-config.ts). Resolution override videoQuality
|
|
8
|
+
* qua withLocalVideoQuality; fps + cameraId đi vào createRtmpRoomFactory. Đổi
|
|
9
|
+
* resolution/fps/cameraId → remount provider (dùng cameraSettingsKey làm `key`)
|
|
10
|
+
* để engine re-prepare.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type AntiFlickerMode = "auto" | "50hz" | "60hz";
|
|
14
|
+
|
|
15
|
+
export interface CameraSettings {
|
|
16
|
+
resolution: 720 | 1080;
|
|
17
|
+
/** 60 chỉ chọn được khi capabilities của lens đang gắn báo hỗ trợ. */
|
|
18
|
+
fps: 24 | 30 | 60;
|
|
19
|
+
/**
|
|
20
|
+
* Chống chớp đèn. Mặc định "50hz" (lưới điện VN) chứ KHÔNG phải "auto": để
|
|
21
|
+
* auto là giao cho HAL đoán tần số, HyperOS đoán trật 60Hz thường xuyên → đèn
|
|
22
|
+
* LED trong nhà vằn trên sóng. Android-only hiện tại.
|
|
23
|
+
*/
|
|
24
|
+
antiFlicker: AntiFlickerMode;
|
|
25
|
+
/**
|
|
26
|
+
* Camera mặc định mỗi lần mở app. null = camera sau mặc định của native. Cần
|
|
27
|
+
* vì máy thật có nhiều camera trùng nhãn (Redmi Note 15: hai "Back Camera").
|
|
28
|
+
*/
|
|
29
|
+
cameraId: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const CAMERA_SETTINGS_STORAGE_KEY = "@pickleball/expo-sdk/camera-settings";
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_CAMERA_SETTINGS: CameraSettings = Object.freeze({
|
|
35
|
+
resolution: 720,
|
|
36
|
+
fps: 30,
|
|
37
|
+
antiFlicker: "50hz",
|
|
38
|
+
cameraId: null,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Parse thuần — giá trị lạ/thiếu rơi về mặc định (config cũ chưa có field vẫn đọc được). */
|
|
42
|
+
export function parseCameraSettings(
|
|
43
|
+
raw: string | null,
|
|
44
|
+
defaults: CameraSettings = DEFAULT_CAMERA_SETTINGS,
|
|
45
|
+
): CameraSettings {
|
|
46
|
+
if (!raw) return { ...defaults };
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(raw) as Partial<CameraSettings> | null;
|
|
49
|
+
if (parsed === null || typeof parsed !== "object") return { ...defaults };
|
|
50
|
+
return {
|
|
51
|
+
resolution: parsed.resolution === 1080 ? 1080 : parsed.resolution === 720 ? 720 : defaults.resolution,
|
|
52
|
+
fps:
|
|
53
|
+
parsed.fps === 24 || parsed.fps === 30 || parsed.fps === 60 ? parsed.fps : defaults.fps,
|
|
54
|
+
antiFlicker:
|
|
55
|
+
parsed.antiFlicker === "auto" || parsed.antiFlicker === "60hz" || parsed.antiFlicker === "50hz"
|
|
56
|
+
? parsed.antiFlicker
|
|
57
|
+
: defaults.antiFlicker,
|
|
58
|
+
cameraId: typeof parsed.cameraId === "string" ? parsed.cameraId : null,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return { ...defaults };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CameraSettingsStore {
|
|
66
|
+
readonly key: string;
|
|
67
|
+
get(): CameraSettings;
|
|
68
|
+
set(patch: Partial<CameraSettings>): void;
|
|
69
|
+
subscribe(listener: () => void): () => void;
|
|
70
|
+
/** Nạp từ đĩa; gọi một lần lúc app khởi động. */
|
|
71
|
+
restore(): Promise<void>;
|
|
72
|
+
isRestored(): boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CameraSettingsStoreOptions {
|
|
76
|
+
storage?: SdkStorage;
|
|
77
|
+
/** Khoá lưu — app fleet giữ khoá cũ để setting sống qua OTA. */
|
|
78
|
+
key?: string;
|
|
79
|
+
defaults?: Partial<CameraSettings>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createCameraSettingsStore(
|
|
83
|
+
options: CameraSettingsStoreOptions = {},
|
|
84
|
+
): CameraSettingsStore {
|
|
85
|
+
const storage = options.storage ?? createDefaultStorage();
|
|
86
|
+
const key = options.key ?? CAMERA_SETTINGS_STORAGE_KEY;
|
|
87
|
+
const defaults: CameraSettings = { ...DEFAULT_CAMERA_SETTINGS, ...options.defaults };
|
|
88
|
+
let settings: CameraSettings = { ...defaults };
|
|
89
|
+
let restored = false;
|
|
90
|
+
const listeners = new Set<() => void>();
|
|
91
|
+
const emit = () => {
|
|
92
|
+
for (const listener of listeners) listener();
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
key,
|
|
96
|
+
get: () => settings,
|
|
97
|
+
set(patch) {
|
|
98
|
+
settings = { ...settings, ...patch };
|
|
99
|
+
void storage.setItem(key, JSON.stringify(settings)).catch(() => undefined);
|
|
100
|
+
emit();
|
|
101
|
+
},
|
|
102
|
+
subscribe(listener) {
|
|
103
|
+
listeners.add(listener);
|
|
104
|
+
return () => {
|
|
105
|
+
listeners.delete(listener);
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
async restore() {
|
|
109
|
+
const raw = await storage.getItem(key).catch(() => null);
|
|
110
|
+
settings = parseCameraSettings(raw, defaults);
|
|
111
|
+
restored = true;
|
|
112
|
+
emit();
|
|
113
|
+
},
|
|
114
|
+
isRestored: () => restored,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function useCameraSettings(store: CameraSettingsStore): CameraSettings {
|
|
119
|
+
return useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `key` cho PickleballLiveProvider: đổi giá trị → remount → engine re-prepare đúng cấu hình. */
|
|
123
|
+
export function cameraSettingsKey(settings: CameraSettings): string {
|
|
124
|
+
return `${settings.resolution}:${settings.fps}:${settings.cameraId ?? "default"}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** roomFactory RTMP theo cấu hình (fps + camera mặc định). */
|
|
128
|
+
export function rtmpRoomFactoryFor(settings: CameraSettings): () => RoomAdapter {
|
|
129
|
+
return createRtmpRoomFactory({
|
|
130
|
+
frameRate: settings.fps,
|
|
131
|
+
...(settings.cameraId ? { cameraId: settings.cameraId } : {}),
|
|
132
|
+
});
|
|
133
|
+
}
|
package/src/contracts.ts
CHANGED
|
@@ -22,9 +22,23 @@ export type SdkErrorCode =
|
|
|
22
22
|
| "SDK_DISABLED"
|
|
23
23
|
| "UNKNOWN";
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Vì sao 409 SESSION_CONFLICT bị nhận — ba ca cần app hành xử KHÁC NHAU mà
|
|
27
|
+
* trước đây chỉ phân biệt được bằng đọc chuỗi tiếng Anh trong `message`:
|
|
28
|
+
* - `superseded`: grant này đã bị grant mới thay (đối tác cấp lại camera-grant)
|
|
29
|
+
* → app DỪNG tại chỗ, không `end`, không quay số lại.
|
|
30
|
+
* - `ended`: phiên đã kết thúc → dọn state, không thử lại.
|
|
31
|
+
* - `expired`: phiên đã quá trần thời lượng credential → xin grant/phiên mới.
|
|
32
|
+
*/
|
|
33
|
+
export type SdkConflictReason = "superseded" | "ended" | "expired";
|
|
34
|
+
|
|
25
35
|
export interface SdkError {
|
|
26
36
|
code: SdkErrorCode;
|
|
27
37
|
message: string;
|
|
38
|
+
/** Chỉ có ở 409 SESSION_CONFLICT của namespace /api/v2/camera. */
|
|
39
|
+
reason?: SdkConflictReason;
|
|
40
|
+
/** Chỉ có ở 429 — cùng số với header `Retry-After` (giây, làm tròn lên). */
|
|
41
|
+
retryAfterMs?: number;
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export interface BootstrapInput {
|
|
@@ -67,6 +81,14 @@ export interface StartLivestreamInput {
|
|
|
67
81
|
* Engine tự đặt cờ này theo options.autoPublish; app không cần truyền.
|
|
68
82
|
*/
|
|
69
83
|
standby?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Độ phân giải app THỰC SỰ quay, để server ghi đúng vào phiên. Chỉ app biết
|
|
86
|
+
* con số này: nó là lựa chọn local và app còn override `config.videoQuality`
|
|
87
|
+
* mà server trả về. Không khai thì server dùng mặc định của nó (1080) và bản
|
|
88
|
+
* ghi phiên nói dối — đã cắn 18/08/2026, dashboard báo 1080 trong khi máy
|
|
89
|
+
* phát 720 suốt.
|
|
90
|
+
*/
|
|
91
|
+
quality?: 720 | 1080;
|
|
70
92
|
}
|
|
71
93
|
|
|
72
94
|
export interface SdkTelemetryCredentials {
|
|
@@ -92,6 +114,38 @@ export interface SdkSessionRefresh {
|
|
|
92
114
|
config: SdkRemoteConfig;
|
|
93
115
|
}
|
|
94
116
|
|
|
117
|
+
export type CameraGrantStatus = "scheduled" | "live";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Camera grant — điện thoại của user ĐỐI TÁC lên sóng (ADR-0002). Backend đối
|
|
121
|
+
* tác nhận từ REST v1 (POST /api/v1/live-sessions {cameraGrant:true} hoặc
|
|
122
|
+
* POST /api/v1/live-sessions/{id}/camera-grant) và chuyển NGUYÊN TRẠNG cho app;
|
|
123
|
+
* app dùng `telemetry.token` làm Bearer với namespace /api/v2/camera/sessions.
|
|
124
|
+
*
|
|
125
|
+
* Superset nghiêm ngặt của SdkSessionGrant: `serverUrl` là RTMP ingest
|
|
126
|
+
* (`rtmp://host:1935/`), `participantToken` là stream key — SDK 0.2 đã nhận
|
|
127
|
+
* hình dạng này qua isSessionGrant nên không cần validator mới ở lớp engine.
|
|
128
|
+
*/
|
|
129
|
+
export interface CameraGrant extends SdkSessionGrant {
|
|
130
|
+
/** Không bao giờ "ended" — endpoint trả 409 thay vì grant. */
|
|
131
|
+
status: CameraGrantStatus;
|
|
132
|
+
matchRef: string | null;
|
|
133
|
+
courtRef: string | null;
|
|
134
|
+
watchUrl: string | null;
|
|
135
|
+
/**
|
|
136
|
+
* Origin websocket Convex (https://<dep>.convex.cloud) để SDK subscribe lệnh
|
|
137
|
+
* thay vì poll HTTP 2s; null khi deployment không lộ origin chuẩn → SDK poll.
|
|
138
|
+
*/
|
|
139
|
+
convexUrl: string | null;
|
|
140
|
+
issuedAt: number;
|
|
141
|
+
/**
|
|
142
|
+
* Hạn dùng của grant. Phiên chờ mà app CHƯA từng kết nối bị dọn sau 30 phút
|
|
143
|
+
* (RTMP_STANDBY_SILENT_MS) — xin grant đúng lúc user mở camera, đừng cấp sớm
|
|
144
|
+
* hàng giờ trước trận.
|
|
145
|
+
*/
|
|
146
|
+
expiresAt: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
95
149
|
export interface EndLivestreamInput {
|
|
96
150
|
sessionId: string;
|
|
97
151
|
reason:
|
|
@@ -123,6 +177,25 @@ export interface SdkTelemetryEvent {
|
|
|
123
177
|
data?: Record<string, unknown>;
|
|
124
178
|
}
|
|
125
179
|
|
|
180
|
+
/** Lệnh điều khiển phiên giao qua kênh poll (mode device-rtmp). */
|
|
181
|
+
export type SessionCommandAction = "start" | "stop" | "end";
|
|
182
|
+
|
|
183
|
+
export interface SessionCommandRecord {
|
|
184
|
+
id: string;
|
|
185
|
+
action: SessionCommandAction;
|
|
186
|
+
/** Con trỏ tăng nghiêm ngặt theo phiên — client gửi lại làm `after`. */
|
|
187
|
+
issuedAt: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface SessionCommandPollResult {
|
|
191
|
+
commands: SessionCommandRecord[];
|
|
192
|
+
/**
|
|
193
|
+
* Trạng thái phiên phía server — lưới an toàn khi lệnh rơi: "live" mà máy
|
|
194
|
+
* còn standby thì tự phát, "ended" thì tự kết thúc.
|
|
195
|
+
*/
|
|
196
|
+
status: "scheduled" | "live" | "ended";
|
|
197
|
+
}
|
|
198
|
+
|
|
126
199
|
export interface LivestreamSessionProvider {
|
|
127
200
|
bootstrap(input: BootstrapInput): Promise<SdkBootstrap>;
|
|
128
201
|
start(input: StartLivestreamInput): Promise<SdkSessionGrant>;
|