@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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Luật quyết định khi nối lại một phiên server còn giữ cho máy này.
|
|
3
|
+
*
|
|
4
|
+
* Chuyển từ apps/device-streamer (src/resume-policy.ts) vào SDK để app đối tác
|
|
5
|
+
* không phải học lại từng cái bẫy đã đo được trên fleet.
|
|
6
|
+
*
|
|
7
|
+
* Vì sao cần tách riêng: bản đầu coi MỌI thất bại là "phiên đã chết" và bỏ cuộc
|
|
8
|
+
* vĩnh viễn sau đúng một lần thử. Hệ quả: máy khởi động lại lúc wifi chưa kịp
|
|
9
|
+
* lên là mất luôn cơ hội nối lại — đúng ca hay gặp nhất, vì máy vừa mở nguồn
|
|
10
|
+
* thì wifi luôn lên chậm hơn app.
|
|
11
|
+
*
|
|
12
|
+
* Phân biệt hai loại thất bại là toàn bộ giá trị của module này:
|
|
13
|
+
* - "gone": server nói phiên không còn. Thử lại bao nhiêu cũng vô ích.
|
|
14
|
+
* - "retry": mạng chưa lên, server trục trặc, phản hồi méo. Phiên có thể vẫn sống.
|
|
15
|
+
*
|
|
16
|
+
* Nghi ngờ thì chọn "retry": đoán nhầm thành "gone" là mất phiên vĩnh viễn (link
|
|
17
|
+
* xem đổi, VOD tách đôi); đoán nhầm thành "retry" chỉ tốn vài request, và đã có
|
|
18
|
+
* lưới server dọn phiên mồ côi.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Phân loại phản hồi HTTP của endpoint refresh. */
|
|
22
|
+
export type ResumeVerdict = "ok" | "gone" | "retry";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 404 = phiên biến mất hẳn. 409 = phiên đã kết thúc (mọi namespace trả
|
|
26
|
+
* SESSION_CONFLICT cho thao tác trên phiên ended, và grant bị thay cũng là 409).
|
|
27
|
+
* 410 = Gone theo chuẩn HTTP. Mọi mã khác — kể cả 401/403 — đều là "retry":
|
|
28
|
+
* token có thể được cấp lại ở nhịp sau, và một lỗi xác thực tạm thời không phải
|
|
29
|
+
* bằng chứng phiên đã chết.
|
|
30
|
+
*/
|
|
31
|
+
export function classifyResumeStatus(status: number): ResumeVerdict {
|
|
32
|
+
if (status === 404 || status === 409 || status === 410) return "gone";
|
|
33
|
+
if (status >= 200 && status < 300) return "ok";
|
|
34
|
+
return "retry";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Nhịp thử lại. Nhịp đầu 1 giây vì ca điển hình là "app mở trước khi wifi lên" —
|
|
39
|
+
* chờ vài giây là xong. Trần 15 giây để một máy chết hẳn không nện server mãi;
|
|
40
|
+
* số lần thì KHÔNG giới hạn: thà thử mãi còn hơn mất phiên, và lưới server
|
|
41
|
+
* (1 giờ cho live, 30 phút cho standby) mới là thứ quyết định lúc nào bỏ cuộc.
|
|
42
|
+
*/
|
|
43
|
+
export const RESUME_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000];
|
|
44
|
+
|
|
45
|
+
export function resumeRetryDelayMs(attempt: number): number {
|
|
46
|
+
const index = Math.min(Math.max(attempt, 0), RESUME_RETRY_DELAYS_MS.length - 1);
|
|
47
|
+
return RESUME_RETRY_DELAYS_MS[index];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bất kỳ ai định TẠO PHIÊN MỚI tự động (AutoTournament của fleet, useStandbySession
|
|
52
|
+
* của app đối tác) phải NHƯỜNG khi còn phiên chờ nhận lại — nhìn CẢ HAI nguồn.
|
|
53
|
+
*
|
|
54
|
+
* Bản đầu chỉ nhìn serverSession (heartbeat, về SAU vài giây); adoptableSessionId
|
|
55
|
+
* (dấu trên đĩa) có mặt ngay từ lúc engine khởi động xong. Khe hở vài giây đó là
|
|
56
|
+
* đủ để một lượt arm nổ súng trước AutoResume: hoặc đụng chốt marker (app đứng
|
|
57
|
+
* màn LỖI), hoặc tệ hơn — lách qua trong đúng cửa sổ race và tạo phiên chờ THỨ
|
|
58
|
+
* HAI đè phiên live cũ (đo thật đêm 18/08/2026).
|
|
59
|
+
*/
|
|
60
|
+
export function resumeBlocksArm(input: {
|
|
61
|
+
adoptableSessionId: string | null;
|
|
62
|
+
serverSessionId: string | null;
|
|
63
|
+
engineSessionId: string | null;
|
|
64
|
+
}): boolean {
|
|
65
|
+
if (input.engineSessionId !== null) return false;
|
|
66
|
+
return input.adoptableSessionId !== null || input.serverSessionId !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Quy ước externalSessionId của lượt start NHẬN LẠI — engine chỉ cho lượt này qua chốt marker. */
|
|
70
|
+
export function resumeExternalSessionId(sessionId: string): string {
|
|
71
|
+
return `resume-${sessionId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Grant nối lại chỉ được tiêu thụ cho ĐÚNG phiên nó được cấp. AutoResume có thể
|
|
76
|
+
* bắn nhiều lượt (retry, đổi nguồn); không kiểm khớp thì một grant cũ còn sót có
|
|
77
|
+
* thể bị lượt start của phiên khác nuốt — engine nối vào nhầm phiên, đúng loại lỗi
|
|
78
|
+
* âm thầm khó truy nhất.
|
|
79
|
+
*/
|
|
80
|
+
export function resumeGrantMatches(
|
|
81
|
+
externalSessionId: string | undefined,
|
|
82
|
+
grantSessionId: string,
|
|
83
|
+
): boolean {
|
|
84
|
+
return externalSessionId === resumeExternalSessionId(grantSessionId);
|
|
85
|
+
}
|
package/src/resume.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LivestreamSessionProvider,
|
|
3
|
+
SdkSessionGrant,
|
|
4
|
+
StartLivestreamInput,
|
|
5
|
+
} from "./contracts";
|
|
6
|
+
import { isSdkRemoteConfig } from "./contracts";
|
|
7
|
+
import { classifyResumeStatus, resumeGrantMatches } from "./resume-policy";
|
|
8
|
+
import { isFiniteNumber, isRecord, isTelemetryCredentials, type Fetch } from "./session-grant";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Nhận lại phiên sau khi app bị kill/mở lại — phần PROVIDER.
|
|
12
|
+
*
|
|
13
|
+
* Kịch bản: máy đang phát, app biến mất (sụp nguồn, OEM giết, vuốt tắt). Server
|
|
14
|
+
* còn giữ phiên. Lần mở sau engine thấy dấu trên đĩa (adoptableSessionId) nhưng
|
|
15
|
+
* KHÔNG có grant trong bộ nhớ — phải hỏi server cấp lại credentials của đúng
|
|
16
|
+
* phiên đó rồi start bằng grant ấy thay vì tạo phiên mới. Link xem và VOD nhờ
|
|
17
|
+
* vậy liền một mạch.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ResumeAttempt =
|
|
21
|
+
| { kind: "grant"; grant: SdkSessionGrant; live: boolean }
|
|
22
|
+
| { kind: "gone" }
|
|
23
|
+
| { kind: "retry" };
|
|
24
|
+
|
|
25
|
+
export interface ResumableSessionProvider extends LivestreamSessionProvider {
|
|
26
|
+
/** Đặt grant cho lượt start kế tiếp có externalSessionId `resume-<id>`. */
|
|
27
|
+
adoptResumeGrant(grant: SdkSessionGrant): void;
|
|
28
|
+
/** Hỏi server cấp lại grant cho phiên còn sống. */
|
|
29
|
+
resume(sessionId: string): Promise<ResumeAttempt>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Bọc provider: `start()` ưu tiên grant nhận lại nếu khớp phiên, còn lại tạo phiên
|
|
34
|
+
* mới như thường. Grant chờ nằm trong closure (không biến module) nên hai provider
|
|
35
|
+
* trong cùng app không dẫm nhau.
|
|
36
|
+
*/
|
|
37
|
+
export function withResume(
|
|
38
|
+
provider: LivestreamSessionProvider,
|
|
39
|
+
resume: (sessionId: string) => Promise<ResumeAttempt>,
|
|
40
|
+
): ResumableSessionProvider {
|
|
41
|
+
let pending: SdkSessionGrant | null = null;
|
|
42
|
+
return {
|
|
43
|
+
bootstrap: provider.bootstrap.bind(provider),
|
|
44
|
+
refresh: provider.refresh.bind(provider),
|
|
45
|
+
end: provider.end.bind(provider),
|
|
46
|
+
publish: provider.publish?.bind(provider),
|
|
47
|
+
unpublish: provider.unpublish?.bind(provider),
|
|
48
|
+
async start(input: StartLivestreamInput) {
|
|
49
|
+
const grant = pending;
|
|
50
|
+
if (grant && resumeGrantMatches(input.externalSessionId, grant.sessionId)) {
|
|
51
|
+
pending = null;
|
|
52
|
+
return grant;
|
|
53
|
+
}
|
|
54
|
+
return provider.start(input);
|
|
55
|
+
},
|
|
56
|
+
adoptResumeGrant(grant) {
|
|
57
|
+
pending = grant;
|
|
58
|
+
},
|
|
59
|
+
resume,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Biến phản hồi refresh (đầy đủ) thành grant; méo → null để caller coi là retry. */
|
|
64
|
+
export function grantFromRefreshPayload(data: unknown): SdkSessionGrant | null {
|
|
65
|
+
if (
|
|
66
|
+
!isRecord(data) ||
|
|
67
|
+
typeof data.sessionId !== "string" ||
|
|
68
|
+
typeof data.serverUrl !== "string" ||
|
|
69
|
+
typeof data.participantToken !== "string" ||
|
|
70
|
+
!isFiniteNumber(data.tokenExpiresAt) ||
|
|
71
|
+
!isTelemetryCredentials(data.telemetry) ||
|
|
72
|
+
!isSdkRemoteConfig(data.config)
|
|
73
|
+
) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
sessionId: data.sessionId,
|
|
78
|
+
serverUrl: data.serverUrl,
|
|
79
|
+
participantToken: data.participantToken,
|
|
80
|
+
playbackUrl: typeof data.playbackUrl === "string" ? data.playbackUrl : null,
|
|
81
|
+
tokenExpiresAt: data.tokenExpiresAt,
|
|
82
|
+
telemetry: data.telemetry,
|
|
83
|
+
config: data.config,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface HttpResumeFetcherOptions {
|
|
88
|
+
/** URL endpoint refresh của phiên — namespace nào cũng được, miễn trả grant đầy đủ + status. */
|
|
89
|
+
url(sessionId: string): string;
|
|
90
|
+
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
|
91
|
+
fetch?: Fetch;
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Gọi endpoint refresh để dựng lại grant. Trả về PHÂN LOẠI chứ không phải
|
|
97
|
+
* null-cho-mọi-thứ (xem resume-policy): mất mạng/5xx/méo = retry, 404/409/410 =
|
|
98
|
+
* gone. Chỉ phát tiếp khi server xác nhận phiên ĐANG live — thiếu trường status
|
|
99
|
+
* coi như không phát: để camera tự lên sóng nhầm còn tệ hơn là chờ một lệnh nữa.
|
|
100
|
+
*/
|
|
101
|
+
export function createHttpResumeFetcher(
|
|
102
|
+
options: HttpResumeFetcherOptions,
|
|
103
|
+
): (sessionId: string) => Promise<ResumeAttempt> {
|
|
104
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
105
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
106
|
+
return async (sessionId) => {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
109
|
+
try {
|
|
110
|
+
const headers =
|
|
111
|
+
typeof options.headers === "function" ? await options.headers() : (options.headers ?? {});
|
|
112
|
+
const response = await fetchImplementation(options.url(sessionId), {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
115
|
+
body: JSON.stringify({}),
|
|
116
|
+
signal: controller.signal,
|
|
117
|
+
});
|
|
118
|
+
const verdict = classifyResumeStatus(response.status);
|
|
119
|
+
if (verdict !== "ok") return { kind: verdict };
|
|
120
|
+
const envelope = (await response.json().catch(() => null)) as { data?: unknown } | null;
|
|
121
|
+
const grant = grantFromRefreshPayload(envelope?.data);
|
|
122
|
+
if (!grant) return { kind: "retry" };
|
|
123
|
+
const status = (envelope?.data as { status?: unknown }).status;
|
|
124
|
+
return { kind: "grant", grant, live: status === "live" };
|
|
125
|
+
} catch {
|
|
126
|
+
// fetch ném = mất mạng. Đây CHÍNH LÀ ca hay gặp nhất khi máy vừa mở nguồn.
|
|
127
|
+
return { kind: "retry" };
|
|
128
|
+
} finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/rtmp-room-adapter.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
RoomConnection,
|
|
6
6
|
RoomEvent,
|
|
7
7
|
} from "./engine";
|
|
8
|
+
import { VIDEO_BITRATE_BPS } from "./uplink-check";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Trạng thái từ tầng RTMP native (trùng NativeRtmpStatus của @pickleball/rtmp-native).
|
|
@@ -14,6 +15,13 @@ export type RtmpStatus =
|
|
|
14
15
|
| { readonly status: "disconnected"; readonly reason?: string }
|
|
15
16
|
| { readonly status: "error"; readonly code: string; readonly message: string };
|
|
16
17
|
|
|
18
|
+
/** Phản hồi băng thông từ native (trùng NativeRtmpBandwidth của rtmp-native). */
|
|
19
|
+
export interface RtmpBandwidth {
|
|
20
|
+
/** false = encoder đang đẩy nhanh hơn đường truyền chịu được. */
|
|
21
|
+
readonly sufficient: boolean;
|
|
22
|
+
readonly videoBitrate: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Dấu hiệu để UI biết render nguồn nào. LiveKit trả về một track object; RTMP
|
|
19
27
|
* không có "track" ở JS (khung hình đi thẳng trong native), nên trả marker này
|
|
@@ -25,7 +33,23 @@ export interface RtmpPreviewMarker {
|
|
|
25
33
|
|
|
26
34
|
/** Bề mặt native mà adapter cần — tiêm vào để test được không cần thiết bị thật. */
|
|
27
35
|
export interface NativeRtmpBindings {
|
|
28
|
-
prepare(options: {
|
|
36
|
+
prepare(options: {
|
|
37
|
+
quality: 720 | 1080;
|
|
38
|
+
landscape: boolean;
|
|
39
|
+
/** fps mong muốn (24 tiết kiệm băng thông, 30 mặc định) — thiếu = native tự chọn. */
|
|
40
|
+
frameRate?: 24 | 30 | 60;
|
|
41
|
+
/**
|
|
42
|
+
* Camera mở ngay từ đầu. Thiếu = native dùng camera sau mặc định. Đặt ở đây
|
|
43
|
+
* thay vì gọi selectCamera sau prepare để không phải mở camera hai lần.
|
|
44
|
+
*/
|
|
45
|
+
cameraId?: string;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Xin quyền camera/mic bằng API quyền thuần (PermissionsAndroid, ...).
|
|
49
|
+
* Không có thì engine rơi về getUserMedia của WebRTC — thứ mở camera thật
|
|
50
|
+
* và đụng HaishinKit trên máy Android chỉ cho một camera mở cùng lúc.
|
|
51
|
+
*/
|
|
52
|
+
requestPermissions?(): Promise<boolean>;
|
|
29
53
|
/** Hướng cầm máy hiện tại — cùng nguồn mà LiveKitRoomAdapter dùng. */
|
|
30
54
|
isLandscape?(): boolean;
|
|
31
55
|
connect(options: {
|
|
@@ -42,6 +66,10 @@ export interface NativeRtmpBindings {
|
|
|
42
66
|
setVideoBitrate(bitsPerSecond: number): Promise<void>;
|
|
43
67
|
dispose(): Promise<void>;
|
|
44
68
|
addStatusListener(listener: (event: RtmpStatus) => void): { remove(): void };
|
|
69
|
+
/** Optional: bản native cũ chưa phát onBandwidth thì adapter bỏ qua thích ứng. */
|
|
70
|
+
addBandwidthListener?(
|
|
71
|
+
listener: (event: RtmpBandwidth) => void,
|
|
72
|
+
): { remove(): void };
|
|
45
73
|
}
|
|
46
74
|
|
|
47
75
|
/**
|
|
@@ -49,10 +77,50 @@ export interface NativeRtmpBindings {
|
|
|
49
77
|
* (720p 3–6 Mbps, 1080p 6–9 Mbps). Thấp hơn khoảng này thì thể thao chuyển động
|
|
50
78
|
* nhanh như pickleball bị bể khối rất rõ.
|
|
51
79
|
*/
|
|
52
|
-
const VIDEO_BITRATE: Record<720 | 1080, number
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
const VIDEO_BITRATE: Readonly<Record<720 | 1080, number>> = VIDEO_BITRATE_BPS;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Thích ứng bitrate theo phản hồi băng thông của native (thay cho BWE mà
|
|
84
|
+
* WebRTC cho không). VIDEO_BITRATE là TRẦN, không phải điều kiện: máy/mạng
|
|
85
|
+
* không kham nổi 6 Mbps thì hạ dần xuống tới sàn và VẪN PHÁT — thà mờ còn
|
|
86
|
+
* hơn đứng hình hay tràn hàng đợi rồi rớt luồng.
|
|
87
|
+
*
|
|
88
|
+
* Hạ nhanh (×0.7 mỗi tín hiệu nghẽn ~2s), hồi chậm (×1.15 sau
|
|
89
|
+
* RECOVER_AFTER_SUFFICIENT tín hiệu tốt liên tiếp ~10s) — mô hình AIMD.
|
|
90
|
+
*/
|
|
91
|
+
export const BITRATE_FLOOR = 800_000;
|
|
92
|
+
export const DEGRADE_FACTOR = 0.7;
|
|
93
|
+
export const RECOVER_FACTOR = 1.15;
|
|
94
|
+
export const RECOVER_AFTER_SUFFICIENT = 5;
|
|
95
|
+
|
|
96
|
+
export interface AdaptiveBitrateState {
|
|
97
|
+
readonly current: number;
|
|
98
|
+
readonly ceiling: number;
|
|
99
|
+
readonly sufficientStreak: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bước chuyển thuần — tách khỏi adapter để test không cần thiết bị. */
|
|
103
|
+
export function nextBitrateState(
|
|
104
|
+
state: AdaptiveBitrateState,
|
|
105
|
+
event: Pick<RtmpBandwidth, "sufficient">,
|
|
106
|
+
): AdaptiveBitrateState {
|
|
107
|
+
if (!event.sufficient) {
|
|
108
|
+
return {
|
|
109
|
+
...state,
|
|
110
|
+
current: Math.max(BITRATE_FLOOR, Math.round(state.current * DEGRADE_FACTOR)),
|
|
111
|
+
sufficientStreak: 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const streak = state.sufficientStreak + 1;
|
|
115
|
+
if (streak < RECOVER_AFTER_SUFFICIENT || state.current >= state.ceiling) {
|
|
116
|
+
return { ...state, sufficientStreak: Math.min(streak, RECOVER_AFTER_SUFFICIENT) };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...state,
|
|
120
|
+
current: Math.min(state.ceiling, Math.round(state.current * RECOVER_FACTOR)),
|
|
121
|
+
sufficientStreak: 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
56
124
|
|
|
57
125
|
/**
|
|
58
126
|
* Adapter cho mode deliveryMode="device-rtmp".
|
|
@@ -65,21 +133,68 @@ const VIDEO_BITRATE: Record<720 | 1080, number> = {
|
|
|
65
133
|
* dashboard đi đường khác (deviceControl trên Convex) — xem device-agent.
|
|
66
134
|
*/
|
|
67
135
|
export class RtmpRoomAdapter implements RoomAdapter {
|
|
136
|
+
/**
|
|
137
|
+
* RTMP không tự hồi: native chỉ phát hiện đứt (watchdog iOS / onDisconnect
|
|
138
|
+
* Android) và báo "disconnected" — không tự quay số lại. Engine thấy cờ này
|
|
139
|
+
* là chạy vòng redial backoff. Thiếu cờ: phiên ngồi chờ "reconnected" không
|
|
140
|
+
* bao giờ tới rồi chết network_timeout sau 30s (prod 17/08/2026).
|
|
141
|
+
*/
|
|
142
|
+
readonly needsManualReconnect = true;
|
|
143
|
+
|
|
68
144
|
private readonly listeners = new Set<(event: RoomEvent) => void>();
|
|
69
145
|
private nativeSubscription?: { remove(): void };
|
|
146
|
+
private bandwidthSubscription?: { remove(): void };
|
|
70
147
|
private quality: 720 | 1080 = 1080;
|
|
71
148
|
private connected = false;
|
|
149
|
+
private prepared = false;
|
|
150
|
+
/** Khung người dùng chọn trên UI (9:16/16:9) — ưu tiên hơn hướng cầm máy. */
|
|
151
|
+
private captureOrientation?: CaptureOrientation;
|
|
152
|
+
private bitrateState?: AdaptiveBitrateState;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Chỉ tồn tại khi bindings hỗ trợ — engine kiểm tra truthiness để quyết định
|
|
156
|
+
* fallback về runtime.requestPermissions() (getUserMedia), nên KHÔNG được
|
|
157
|
+
* khai báo method luôn-có rồi tự fallback bên trong.
|
|
158
|
+
*/
|
|
159
|
+
readonly requestPermissions?: () => Promise<boolean>;
|
|
72
160
|
|
|
73
|
-
constructor(
|
|
161
|
+
constructor(
|
|
162
|
+
private readonly bindings: NativeRtmpBindings,
|
|
163
|
+
private readonly options: { frameRate?: 24 | 30 | 60; cameraId?: string } = {},
|
|
164
|
+
) {
|
|
165
|
+
const request = bindings.requestPermissions?.bind(bindings);
|
|
166
|
+
if (request) this.requestPermissions = request;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Phần tuỳ chọn của prepare(). Giữ dạng spread có điều kiện: truyền
|
|
171
|
+
* `frameRate: undefined` xuống Expo Record là ghi đè mặc định của native
|
|
172
|
+
* bằng null chứ không phải "bỏ qua trường".
|
|
173
|
+
*/
|
|
174
|
+
private prepareOverrides(): { frameRate?: 24 | 30 | 60; cameraId?: string } {
|
|
175
|
+
return {
|
|
176
|
+
...(this.options.frameRate !== undefined
|
|
177
|
+
? { frameRate: this.options.frameRate }
|
|
178
|
+
: {}),
|
|
179
|
+
...(this.options.cameraId !== undefined
|
|
180
|
+
? { cameraId: this.options.cameraId }
|
|
181
|
+
: {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
74
184
|
|
|
75
185
|
async preparePreview(quality: 720 | 1080): Promise<RtmpPreviewMarker> {
|
|
76
186
|
this.quality = quality;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
187
|
+
// Khung người dùng chọn (9:16/16:9) là nguồn sự thật; chỉ khi chưa chọn mới
|
|
188
|
+
// đoán theo hướng cầm máy (mặc định ngang: sân pickleball quay ngang là
|
|
189
|
+
// chính, và đó cũng là kích thước capture mặc định của HaishinKit).
|
|
79
190
|
await this.bindings.prepare({
|
|
80
191
|
quality,
|
|
81
|
-
landscape: this.
|
|
192
|
+
landscape: this.captureOrientation
|
|
193
|
+
? this.captureOrientation === "landscape"
|
|
194
|
+
: (this.bindings.isLandscape?.() ?? true),
|
|
195
|
+
...this.prepareOverrides(),
|
|
82
196
|
});
|
|
197
|
+
this.prepared = true;
|
|
83
198
|
// Khung hình do PickleballRtmpPreviewView vẽ ở tầng native; JS chỉ cần biết
|
|
84
199
|
// "đã có preview và là nguồn RTMP" để chọn đúng component hiển thị.
|
|
85
200
|
return { kind: "rtmp" };
|
|
@@ -106,6 +221,13 @@ export class RtmpRoomAdapter implements RoomAdapter {
|
|
|
106
221
|
publish: input.publish !== false,
|
|
107
222
|
});
|
|
108
223
|
this.connected = true;
|
|
224
|
+
// Trần theo mức chất lượng của phiên; phản hồi băng thông sẽ điều chỉnh
|
|
225
|
+
// trong khoảng [BITRATE_FLOOR, trần] mà không bao giờ chặn phát.
|
|
226
|
+
this.bitrateState = {
|
|
227
|
+
current: VIDEO_BITRATE[this.quality],
|
|
228
|
+
ceiling: VIDEO_BITRATE[this.quality],
|
|
229
|
+
sufficientStreak: 0,
|
|
230
|
+
};
|
|
109
231
|
}
|
|
110
232
|
|
|
111
233
|
async setPublishing(enabled: boolean) {
|
|
@@ -115,6 +237,7 @@ export class RtmpRoomAdapter implements RoomAdapter {
|
|
|
115
237
|
async disconnect(_stopTracks?: boolean) {
|
|
116
238
|
if (!this.connected) return;
|
|
117
239
|
this.connected = false;
|
|
240
|
+
this.bitrateState = undefined;
|
|
118
241
|
await this.bindings.disconnect();
|
|
119
242
|
}
|
|
120
243
|
|
|
@@ -147,9 +270,19 @@ export class RtmpRoomAdapter implements RoomAdapter {
|
|
|
147
270
|
await this.bindings.setVideoBitrate(bitsPerSecond);
|
|
148
271
|
}
|
|
149
272
|
|
|
150
|
-
async setCaptureOrientation(
|
|
151
|
-
|
|
152
|
-
//
|
|
273
|
+
async setCaptureOrientation(orientation: CaptureOrientation) {
|
|
274
|
+
this.captureOrientation = orientation;
|
|
275
|
+
// Đang preview (chưa phát): prepare lại để đổi khung 9:16 ↔ 16:9 ngay —
|
|
276
|
+
// native chỉ đổi screen.frame + gắn lại camera, encoder chưa chạy.
|
|
277
|
+
// Đã kết nối thì KHÔNG đổi giữa luồng: buộc khởi động lại encoder và làm
|
|
278
|
+
// thủng segment HLS; lựa chọn sẽ áp cho lần prepare kế tiếp.
|
|
279
|
+
if (this.prepared && !this.connected) {
|
|
280
|
+
await this.bindings.prepare({
|
|
281
|
+
quality: this.quality,
|
|
282
|
+
landscape: orientation === "landscape",
|
|
283
|
+
...this.prepareOverrides(),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
153
286
|
}
|
|
154
287
|
|
|
155
288
|
subscribe(listener: (event: RoomEvent) => void) {
|
|
@@ -160,6 +293,11 @@ export class RtmpRoomAdapter implements RoomAdapter {
|
|
|
160
293
|
if (mapped) this.emit(mapped);
|
|
161
294
|
});
|
|
162
295
|
}
|
|
296
|
+
if (!this.bandwidthSubscription && this.bindings.addBandwidthListener) {
|
|
297
|
+
this.bandwidthSubscription = this.bindings.addBandwidthListener((event) =>
|
|
298
|
+
this.handleBandwidth(event),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
163
301
|
return () => {
|
|
164
302
|
this.listeners.delete(listener);
|
|
165
303
|
};
|
|
@@ -168,11 +306,25 @@ export class RtmpRoomAdapter implements RoomAdapter {
|
|
|
168
306
|
async dispose() {
|
|
169
307
|
this.nativeSubscription?.remove();
|
|
170
308
|
this.nativeSubscription = undefined;
|
|
309
|
+
this.bandwidthSubscription?.remove();
|
|
310
|
+
this.bandwidthSubscription = undefined;
|
|
171
311
|
this.listeners.clear();
|
|
172
312
|
this.connected = false;
|
|
313
|
+
this.bitrateState = undefined;
|
|
173
314
|
await this.bindings.dispose();
|
|
174
315
|
}
|
|
175
316
|
|
|
317
|
+
private handleBandwidth(event: RtmpBandwidth) {
|
|
318
|
+
const state = this.bitrateState;
|
|
319
|
+
if (!this.connected || !state) return;
|
|
320
|
+
const next = nextBitrateState(state, event);
|
|
321
|
+
this.bitrateState = next;
|
|
322
|
+
if (next.current !== state.current) {
|
|
323
|
+
// Fire-and-forget: chỉnh bitrate lỗi thì giữ mức cũ chứ không rớt luồng.
|
|
324
|
+
void this.bindings.setVideoBitrate(next.current).catch(() => undefined);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
176
328
|
private emit(event: RoomEvent) {
|
|
177
329
|
for (const listener of this.listeners) listener(event);
|
|
178
330
|
}
|