@pickleball/rtmp-native 0.1.0 → 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/android/build.gradle +15 -6
- package/android/src/main/AndroidManifest.xml +26 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/CameraSession.kt +252 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/Encoders.kt +222 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/GlCompositor.kt +755 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpNativeModule.kt +975 -135
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpPreviewView.kt +119 -23
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpService.kt +126 -0
- package/ios/PickleballRtmpNativeModule.swift +1161 -8
- package/package.json +3 -3
- package/src/PickleballRtmpNative.types.ts +140 -0
- package/src/PickleballRtmpNativeModule.ts +69 -0
|
@@ -1,43 +1,131 @@
|
|
|
1
1
|
package expo.modules.pickleballrtmp
|
|
2
2
|
|
|
3
|
+
import android.app.ActivityManager
|
|
4
|
+
import android.app.ApplicationExitInfo
|
|
3
5
|
import android.content.Context
|
|
4
|
-
import android.
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.IntentFilter
|
|
8
|
+
import android.graphics.Bitmap
|
|
9
|
+
import android.graphics.Matrix
|
|
5
10
|
import android.hardware.camera2.CameraCharacteristics
|
|
6
11
|
import android.hardware.camera2.CameraManager
|
|
7
|
-
import android.
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import
|
|
12
|
+
import android.hardware.camera2.CameraMetadata
|
|
13
|
+
import android.media.MediaCodec
|
|
14
|
+
import android.os.BatteryManager
|
|
15
|
+
import android.os.Build
|
|
16
|
+
import android.os.Handler
|
|
17
|
+
import android.os.HandlerThread
|
|
18
|
+
import android.os.PowerManager
|
|
19
|
+
import android.util.Base64
|
|
20
|
+
import android.view.PixelCopy
|
|
21
|
+
import android.view.Surface
|
|
22
|
+
import com.pedro.common.ConnectChecker
|
|
23
|
+
import com.pedro.rtmp.rtmp.RtmpClient
|
|
13
24
|
import expo.modules.kotlin.functions.Coroutine
|
|
14
25
|
import expo.modules.kotlin.modules.Module
|
|
15
26
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
16
27
|
import expo.modules.kotlin.records.Field
|
|
17
28
|
import expo.modules.kotlin.records.Record
|
|
18
29
|
import expo.modules.kotlin.exception.CodedException
|
|
30
|
+
import kotlinx.coroutines.CoroutineScope
|
|
31
|
+
import kotlinx.coroutines.Dispatchers
|
|
32
|
+
import kotlinx.coroutines.Job
|
|
33
|
+
import kotlinx.coroutines.SupervisorJob
|
|
34
|
+
import kotlinx.coroutines.delay
|
|
35
|
+
import kotlinx.coroutines.isActive
|
|
36
|
+
import kotlinx.coroutines.launch
|
|
37
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
38
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
39
|
+
import java.io.ByteArrayOutputStream
|
|
40
|
+
import java.nio.ByteBuffer
|
|
41
|
+
import kotlin.coroutines.resume
|
|
19
42
|
|
|
20
43
|
/**
|
|
21
|
-
*
|
|
22
|
-
* ios/PickleballRtmpNativeModule.swift.
|
|
44
|
+
* Cầu nối deliveryMode="device-rtmp" cho Android.
|
|
23
45
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
46
|
+
* ===== KIẾN TRÚC (viết lại 2026-08-08) =====
|
|
47
|
+
*
|
|
48
|
+
* Camera2 (1 session, 2 target) ──┬─→ SurfaceView preview
|
|
49
|
+
* └─→ MediaCodec Surface ──→ RtmpClient
|
|
50
|
+
*
|
|
51
|
+
* KHÔNG có tầng OpenGL nào. Bản cũ đi qua MediaMixer của HaishinKit
|
|
52
|
+
* (camera → texture OES → compose GL → xuất) và tầng đó là nguồn răng cưa:
|
|
53
|
+
* `gles/screen/Renderer` đặt GL_TEXTURE_MIN_FILTER = GL_NEAREST và quên
|
|
54
|
+
* glBindTexture ở nhánh video, `gles/Framebuffer` đặt NEAREST cả MIN lẫn MAG.
|
|
55
|
+
* Vá cả hai (bản 0.18.2-linear2) vẫn không hết — khuyết tật nằm ở kiến trúc.
|
|
56
|
+
*
|
|
57
|
+
* Đo thực nghiệm trên Redmi Note 15, cùng camera cùng cảnh:
|
|
58
|
+
* Camera2 trần > LiveKit > RootEncoder(full) > HaishinKit. Nên ta dùng Camera2
|
|
59
|
+
* trần cho hình, và CHỈ mượn module `rtmp` của RootEncoder cho giao thức
|
|
60
|
+
* đường truyền — không dùng phần camera/GL của nó.
|
|
61
|
+
*
|
|
62
|
+
* Hệ quả: toàn bộ reflection của Camera2Controls.kt biến mất (zoom/EV/khoá
|
|
63
|
+
* nét/khoá sáng/fps/đèn giờ là CaptureRequest key thường vì ta tự sở hữu
|
|
64
|
+
* session), và không còn hack xoay bằng deviceOrientation.
|
|
65
|
+
*
|
|
66
|
+
* GIỚI HẠN CÓ CHỦ Ý: Android chỉ phát khung NGANG. Xoay sang 9:16 đòi hỏi một
|
|
67
|
+
* bước xoay ảnh — tức đúng tầng GL vừa bỏ. App device-streamer đã khoá
|
|
68
|
+
* landscape nên không ảnh hưởng; bên nào cần 9:16 phải dùng LiveKit.
|
|
27
69
|
*/
|
|
28
70
|
class PickleballRtmpNativeModule : Module() {
|
|
29
|
-
private var
|
|
30
|
-
private
|
|
31
|
-
private
|
|
71
|
+
private var camera: CameraSession? = null
|
|
72
|
+
private val video = VideoEncoder()
|
|
73
|
+
private val audio = AudioEncoder()
|
|
74
|
+
|
|
75
|
+
private var rtmp: RtmpClient? = null
|
|
32
76
|
/** Phải khớp mặc định của engine (`isCameraFront: false`) như bên iOS. */
|
|
33
77
|
private var cameraId: String = BACK_DEFAULT_ID
|
|
34
78
|
private var microphoneEnabled = true
|
|
35
79
|
private var publishing = false
|
|
80
|
+
private var connected = false
|
|
81
|
+
private var rtmpUrl: String? = null
|
|
36
82
|
private var videoBitrate = 6_000_000
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
private var
|
|
40
|
-
private var
|
|
83
|
+
private var audioBitrate = 128_000
|
|
84
|
+
/** Luôn giữ dạng NGANG (cạnh dài trước). */
|
|
85
|
+
private var captureWidth = 1920
|
|
86
|
+
private var captureHeight = 1080
|
|
87
|
+
private var frameRate = 30
|
|
88
|
+
/** SPS/PPS lấy từ encoder — RtmpClient cần TRƯỚC khi connect. */
|
|
89
|
+
private var sps: ByteBuffer? = null
|
|
90
|
+
private var pps: ByteBuffer? = null
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mốc PTS đầu của MỖI kết nối RTMP — trừ đi để luồng bắt đầu từ ~0. Nguồn
|
|
94
|
+
* PTS thô là đồng hồ nội bộ máy (camera timestamp / System.nanoTime), hai
|
|
95
|
+
* track hai đồng hồ KHÁC nhau: đo 26/08 video mở màn ở 10,86 GIỜ còn audio
|
|
96
|
+
* 26,5 giờ — player hiện timecode 10 tiếng. Reset mỗi connect() để lần quay
|
|
97
|
+
* số lại cũng sạch (ingest xử discontinuity sẵn).
|
|
98
|
+
*/
|
|
99
|
+
@Volatile private var videoPtsBaseUs = -1L
|
|
100
|
+
@Volatile private var audioPtsBaseUs = -1L
|
|
101
|
+
|
|
102
|
+
/** State controls đã chọn — camera mở lại (đổi lens) là reset nên áp lại. */
|
|
103
|
+
private var zoomRatio: Float? = null
|
|
104
|
+
private var exposureBiasEv: Float? = null
|
|
105
|
+
private var focusLocked = false
|
|
106
|
+
private var exposureLocked = false
|
|
107
|
+
private var torchEnabled = false
|
|
108
|
+
private var antibandingMode: Int? = null
|
|
109
|
+
|
|
110
|
+
/** Overlay tĩnh (composite/logo đã nướng) + các lớp động — như bên iOS. */
|
|
111
|
+
private var overlayLogoSpec: OverlayLogoOptions? = null
|
|
112
|
+
private var overlayAnimatedSpecs: List<OverlayAnimatedOptions> = emptyList()
|
|
113
|
+
/** Lớp CHỮ đã điền sẵn (tỉ số/tên đội) — cập nhật ~2 lần/phút theo pha bóng. */
|
|
114
|
+
private var overlayTextSpecs: List<OverlayTextOptions> = emptyList()
|
|
115
|
+
/** Pass GL — CHỈ tồn tại khi có overlay; null = đường addTarget thẳng như cũ. */
|
|
116
|
+
private var compositor: GlCompositor? = null
|
|
117
|
+
|
|
118
|
+
private val hasOverlay: Boolean
|
|
119
|
+
get() =
|
|
120
|
+
overlayLogoSpec != null || overlayAnimatedSpecs.isNotEmpty() || overlayTextSpecs.isNotEmpty()
|
|
121
|
+
|
|
122
|
+
private val bandwidthScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
123
|
+
/** Dựng lại camera khi surface preview đổi — tách scope để không đụng bandwidth. */
|
|
124
|
+
private val reattachScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
125
|
+
private var bandwidthJob: Job? = null
|
|
126
|
+
private var copyThread: HandlerThread? = null
|
|
127
|
+
/** Surface preview đang nằm trong capture session hiện tại (null = chưa gắn). */
|
|
128
|
+
private var attachedPreview: Surface? = null
|
|
41
129
|
|
|
42
130
|
private val context: Context
|
|
43
131
|
get() = appContext.reactContext ?: throw RtmpException("thiếu Android context")
|
|
@@ -86,7 +174,7 @@ class PickleballRtmpNativeModule : Module() {
|
|
|
86
174
|
AsyncFunction("setMicrophoneEnabled") Coroutine { enabled: Boolean ->
|
|
87
175
|
microphoneEnabled = enabled
|
|
88
176
|
// Gỡ hẳn nguồn audio thay vì mute — mic tắt thì không encode luôn.
|
|
89
|
-
|
|
177
|
+
if (enabled) startAudioIfNeeded() else audio.release()
|
|
90
178
|
Unit
|
|
91
179
|
}
|
|
92
180
|
|
|
@@ -94,184 +182,816 @@ class PickleballRtmpNativeModule : Module() {
|
|
|
94
182
|
applyVideoBitrate(bitsPerSecond)
|
|
95
183
|
}
|
|
96
184
|
|
|
185
|
+
// ===== Camera controls =====
|
|
186
|
+
// Không còn reflection: ta sở hữu CaptureRequest.Builder nên đây là key thường.
|
|
187
|
+
|
|
188
|
+
AsyncFunction("getCameraCapabilities") Coroutine { ->
|
|
189
|
+
cameraCapabilities()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
AsyncFunction("setZoom") Coroutine { factor: Double ->
|
|
193
|
+
zoomRatio = factor.toFloat()
|
|
194
|
+
val active = camera ?: throw RtmpException("chưa có camera — gọi prepare() trước")
|
|
195
|
+
if (!active.setZoom(factor.toFloat())) {
|
|
196
|
+
throw RtmpException("thiết bị không hỗ trợ zoom")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
AsyncFunction("setExposureBias") Coroutine { ev: Double ->
|
|
201
|
+
exposureBiasEv = ev.toFloat()
|
|
202
|
+
val active = camera ?: throw RtmpException("chưa có camera — gọi prepare() trước")
|
|
203
|
+
if (!active.setExposureBias(ev.toFloat())) {
|
|
204
|
+
throw RtmpException("thiết bị không hỗ trợ chỉnh EV")
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
AsyncFunction("setFocusLocked") Coroutine { locked: Boolean ->
|
|
209
|
+
focusLocked = locked
|
|
210
|
+
val active = camera ?: throw RtmpException("chưa có camera — gọi prepare() trước")
|
|
211
|
+
if (!active.setFocusLocked(locked)) throw RtmpException("thiết bị không hỗ trợ khoá nét")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
AsyncFunction("setExposureLocked") Coroutine { locked: Boolean ->
|
|
215
|
+
exposureLocked = locked
|
|
216
|
+
val active = camera ?: throw RtmpException("chưa có camera — gọi prepare() trước")
|
|
217
|
+
if (!active.setExposureLocked(locked)) throw RtmpException("thiết bị không hỗ trợ khoá sáng")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
AsyncFunction("setTorch") Coroutine { enabled: Boolean ->
|
|
221
|
+
torchEnabled = enabled
|
|
222
|
+
camera?.setTorch(enabled)
|
|
223
|
+
Unit
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Chống chớp đèn. Khác các control trên: KHÔNG ném khi chưa có camera —
|
|
227
|
+
// config đẩy xuống lúc app khởi động (trước cả prepare), giá trị được LƯU
|
|
228
|
+
// và applyStoredControls tự áp ở mỗi lần attachCamera. HAL thiếu mode thì
|
|
229
|
+
// setAntibanding trả false và bỏ qua — đường config tự động, không phải
|
|
230
|
+
// thao tác tay cần báo lỗi.
|
|
231
|
+
AsyncFunction("setAntiFlicker") Coroutine { mode: String ->
|
|
232
|
+
val value = when (mode) {
|
|
233
|
+
"50hz" -> CameraMetadata.CONTROL_AE_ANTIBANDING_MODE_50HZ
|
|
234
|
+
"60hz" -> CameraMetadata.CONTROL_AE_ANTIBANDING_MODE_60HZ
|
|
235
|
+
else -> CameraMetadata.CONTROL_AE_ANTIBANDING_MODE_AUTO
|
|
236
|
+
}
|
|
237
|
+
antibandingMode = value
|
|
238
|
+
camera?.setAntibanding(value)
|
|
239
|
+
Unit
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ===== Overlay =====
|
|
243
|
+
// Trả BÁO CÁO như iOS — "áp im lặng" đã bị cấm sau sự cố 26/08.
|
|
244
|
+
|
|
245
|
+
AsyncFunction("setOverlayLogo") Coroutine { options: OverlayLogoOptions? ->
|
|
246
|
+
overlayLogoSpec = options
|
|
247
|
+
applyOverlay()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
AsyncFunction("setOverlayAnimated") Coroutine { items: List<OverlayAnimatedOptions>? ->
|
|
251
|
+
overlayAnimatedSpecs = items ?: emptyList()
|
|
252
|
+
applyOverlay()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
AsyncFunction("setOverlayTexts") Coroutine { items: List<OverlayTextOptions>? ->
|
|
256
|
+
overlayTextSpecs = items ?: emptyList()
|
|
257
|
+
applyOverlayTexts()
|
|
258
|
+
}
|
|
259
|
+
|
|
97
260
|
Function("getStats") {
|
|
98
261
|
mapOf(
|
|
99
|
-
"isConnected" to (
|
|
262
|
+
"isConnected" to (rtmp?.isStreaming ?: false),
|
|
100
263
|
"isPublishing" to publishing,
|
|
101
264
|
"videoBitrate" to videoBitrate,
|
|
102
|
-
"currentFps" to
|
|
265
|
+
"currentFps" to currentFps,
|
|
103
266
|
)
|
|
104
267
|
}
|
|
105
268
|
|
|
269
|
+
AsyncFunction("getDeviceHealth") Coroutine { -> deviceHealth() }
|
|
270
|
+
|
|
271
|
+
// Hộp đen: vì sao tiến trình chết lần trước (Android 11+). Xem processExitReasons().
|
|
272
|
+
AsyncFunction("getProcessExitReasons") Coroutine { max: Int -> processExitReasons(max) }
|
|
273
|
+
|
|
274
|
+
// Chụp 1 ảnh JPEG base64 từ khung preview — dashboard xem trước từ xa.
|
|
275
|
+
AsyncFunction("snapshot") Coroutine { ->
|
|
276
|
+
snapshot()
|
|
277
|
+
}
|
|
278
|
+
|
|
106
279
|
AsyncFunction("dispose") Coroutine { ->
|
|
107
280
|
teardown()
|
|
108
281
|
}
|
|
109
282
|
|
|
110
283
|
OnDestroy {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
284
|
+
stopBandwidthMonitor()
|
|
285
|
+
// Module chết mà service còn sống là thông báo treo vĩnh viễn không ai gỡ
|
|
286
|
+
// được — dispose() không phải lúc nào cũng được gọi trước khi tắt.
|
|
287
|
+
runCatching { PickleballRtmpService.stop(context) }
|
|
288
|
+
runCatching { rtmp?.disconnect() }
|
|
289
|
+
camera?.close()
|
|
290
|
+
camera = null
|
|
291
|
+
runCatching { compositor?.release() }
|
|
292
|
+
compositor = null
|
|
293
|
+
video.release()
|
|
294
|
+
audio.release()
|
|
295
|
+
copyThread?.quitSafely()
|
|
296
|
+
copyThread = null
|
|
297
|
+
PickleballRtmpPreviewRegistry.observe(null)
|
|
116
298
|
}
|
|
117
299
|
}
|
|
118
300
|
|
|
119
301
|
// MARK: - Vòng đời
|
|
120
302
|
|
|
121
303
|
private suspend fun prepare(options: PrepareOptions) {
|
|
122
|
-
val active = mixer ?: MediaMixer(context).also { mixer = it }
|
|
123
|
-
PickleballRtmpPreviewRegistry.setMixer(active)
|
|
124
|
-
|
|
125
304
|
if (options.quality == 720) {
|
|
126
|
-
|
|
127
|
-
|
|
305
|
+
captureWidth = 1280
|
|
306
|
+
captureHeight = 720
|
|
128
307
|
} else {
|
|
129
|
-
|
|
130
|
-
|
|
308
|
+
captureWidth = 1920
|
|
309
|
+
captureHeight = 1080
|
|
131
310
|
}
|
|
132
|
-
|
|
311
|
+
frameRate = options.frameRate
|
|
312
|
+
// Camera mặc định từ cấu hình — chỉ nhận id máy thật sự có, tránh kẹt màn
|
|
313
|
+
// đen khi cấu hình cũ trỏ vào camera không còn tồn tại.
|
|
314
|
+
options.cameraId
|
|
315
|
+
?.takeIf { id -> availableCameras().any { it.id == id } }
|
|
316
|
+
?.let { cameraId = it }
|
|
317
|
+
|
|
318
|
+
// Cỡ capture phải là cỡ camera THẬT hỗ trợ, nếu không HAL tự scale.
|
|
319
|
+
val (width, height) = CameraSession(context).pickSize(cameraId, captureWidth, captureHeight)
|
|
320
|
+
captureWidth = width
|
|
321
|
+
captureHeight = height
|
|
322
|
+
PickleballRtmpPreviewRegistry.setBufferSize(width, height)
|
|
133
323
|
|
|
134
|
-
// PHẢI đặt TRƯỚC startRunning(): Camera2Source.open() đọc `mixer.screen.frame`
|
|
135
|
-
// qua getCameraSize() để chọn kích thước capture. Đặt sau là camera đã chốt
|
|
136
|
-
// kích thước theo frame mặc định rồi, sửa frame lúc đó không còn tác dụng.
|
|
137
|
-
applyScreenGeometry()
|
|
138
324
|
attachCamera(cameraId)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
325
|
+
// Bật foreground service NGAY tại đây, không sớm hơn không muộn hơn: từ
|
|
326
|
+
// Android 12 chỉ được khởi động FGS khi app còn tiền cảnh, mà prepare() là
|
|
327
|
+
// lúc người dùng vừa mở màn camera nên chắc chắn thoả. Camera đã mở xong
|
|
328
|
+
// rồi mới bật để không giữ thông báo khi mở camera thất bại.
|
|
329
|
+
PickleballRtmpService.start(context)
|
|
143
330
|
emitStatus(mapOf("status" to "idle"))
|
|
144
331
|
}
|
|
145
332
|
|
|
146
|
-
/** Kích thước khung thật theo hướng người dùng chọn (9:16 dọc hay 16:9 ngang). */
|
|
147
|
-
private fun orientedSize(): Pair<Int, Int> =
|
|
148
|
-
if (landscape) videoLongEdge to videoShortEdge else videoShortEdge to videoLongEdge
|
|
149
|
-
|
|
150
333
|
/**
|
|
151
|
-
*
|
|
334
|
+
* Mở camera với encoder surface, kèm preview surface NẾU đã có.
|
|
152
335
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* LiveKit không cần bước này vì WebRTC gắn metadata xoay vào TỪNG FRAME rồi
|
|
158
|
-
* để phía phát xoay; HaishinKit nướng thẳng hướng vào ảnh đã mã hoá.
|
|
159
|
-
*
|
|
160
|
-
* Ghi chú: `VideoScreenObject.isRotatesWithContent` (cờ quyết định có cộng
|
|
161
|
-
* `deviceOrientation` vào góc xoay hay không) KHÔNG chạm tới được từ ngoài —
|
|
162
|
-
* `findByClass` là `internal` và `id` của object là UUID ngẫu nhiên nên
|
|
163
|
-
* `findById` cũng vô dụng. Nếu chỉ set frame mà hình vẫn lệch thì phải tự
|
|
164
|
-
* thêm một VideoScreenObject của mình vào screen.
|
|
336
|
+
* KHÔNG được chờ preview: app chỉ mount PickleballRtmpPreviewView khi
|
|
337
|
+
* `previewTrack` khác null, mà previewTrack chỉ có SAU khi prepare() xong —
|
|
338
|
+
* chờ ở đây là bế tắc vòng tròn (đã dính đúng lỗi này). Nên mở trước với
|
|
339
|
+
* encoder, rồi dựng lại session khi surface preview xuất hiện.
|
|
165
340
|
*/
|
|
166
|
-
private fun
|
|
167
|
-
val
|
|
168
|
-
|
|
169
|
-
|
|
341
|
+
private suspend fun attachCamera(id: String) {
|
|
342
|
+
val preview = PickleballRtmpPreviewRegistry.current()
|
|
343
|
+
attachedPreview = preview
|
|
344
|
+
|
|
345
|
+
camera?.close()
|
|
346
|
+
camera = null
|
|
347
|
+
// Encoder phải sẵn sàng TRƯỚC khi mở camera: input surface là một trong hai
|
|
348
|
+
// target của capture session.
|
|
349
|
+
video.release()
|
|
350
|
+
video.prepare(captureWidth, captureHeight, frameRate, videoBitrate)
|
|
351
|
+
val encoderSurface = video.inputSurface
|
|
352
|
+
?: throw RtmpException("không tạo được input surface cho encoder")
|
|
353
|
+
video.start(
|
|
354
|
+
onHeader = { s, p ->
|
|
355
|
+
// Giữ bản sao: buffer của MediaCodec bị tái sử dụng sau khi release.
|
|
356
|
+
sps = cloneBuffer(s)
|
|
357
|
+
pps = p?.let { cloneBuffer(it) }
|
|
358
|
+
},
|
|
359
|
+
onFrame = { buffer, info ->
|
|
360
|
+
if (publishing) {
|
|
361
|
+
if (videoPtsBaseUs < 0) videoPtsBaseUs = info.presentationTimeUs
|
|
362
|
+
info.presentationTimeUs -= videoPtsBaseUs
|
|
363
|
+
rtmp?.sendVideo(buffer, info)
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
// Có overlay → chen pass GL giữa camera và encoder (preview cũng nhận bản
|
|
369
|
+
// đã compose để PixelCopy/dashboard thấy đúng cái đang lên sóng — khớp iOS).
|
|
370
|
+
// Không overlay → giữ NGUYÊN đường addTarget thẳng, zero GL như kiến trúc
|
|
371
|
+
// 08/08. GL dựng hỏng thì rơi về đường thẳng: mất overlay chứ không mất sóng.
|
|
372
|
+
compositor?.release()
|
|
373
|
+
compositor = null
|
|
374
|
+
val targets: List<Surface>
|
|
375
|
+
if (hasOverlay) {
|
|
376
|
+
val gl = runCatching {
|
|
377
|
+
GlCompositor(
|
|
378
|
+
context, captureWidth, captureHeight, encoderSurface,
|
|
379
|
+
cameraRotation = cameraRotationFor(id),
|
|
380
|
+
)
|
|
381
|
+
}.getOrNull()
|
|
382
|
+
if (gl?.inputSurface != null) {
|
|
383
|
+
gl.setLayers(buildLayerSpecs())
|
|
384
|
+
gl.setTextLayers(buildTextSpecs())
|
|
385
|
+
preview?.let { gl.setPreviewSurface(it, captureWidth, captureHeight) }
|
|
386
|
+
compositor = gl
|
|
387
|
+
targets = listOfNotNull(gl.inputSurface)
|
|
388
|
+
} else {
|
|
389
|
+
gl?.release()
|
|
390
|
+
targets = listOfNotNull(preview, encoderSurface)
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
targets = listOfNotNull(preview, encoderSurface)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
val session = CameraSession(context)
|
|
397
|
+
session.open(id, targets, frameRate)
|
|
398
|
+
camera = session
|
|
399
|
+
cameraId = id
|
|
400
|
+
applyStoredControls()
|
|
401
|
+
observePreviewSurface()
|
|
170
402
|
}
|
|
171
403
|
|
|
172
|
-
|
|
173
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Áp trạng thái overlay. Có/không overlay quyết định KIẾN TRÚC pipeline
|
|
406
|
+
* (GL pass vs addTarget thẳng) nên CHUYỂN trạng thái là dựng lại camera —
|
|
407
|
+
* hiếm (BTC lưu giữa trận), chấp nhận khựng ~0.3s như một lần đổi lens.
|
|
408
|
+
* Cùng trạng thái thì chỉ thay danh sách lớp trên compositor đang chạy.
|
|
409
|
+
*/
|
|
410
|
+
private suspend fun applyOverlay(): Map<String, Any> {
|
|
411
|
+
if (camera?.isOpen != true) {
|
|
412
|
+
// Chưa prepare — spec đã lưu, attachCamera lần tới tự mang theo.
|
|
413
|
+
return mapOf("applied" to false, "why" to "no_camera")
|
|
414
|
+
}
|
|
415
|
+
val active = compositor
|
|
416
|
+
if (hasOverlay == (active != null)) {
|
|
417
|
+
return if (active != null) {
|
|
418
|
+
active.setLayers(buildLayerSpecs())
|
|
419
|
+
mapOf("applied" to true, "mode" to "gl", "items" to active.lastLayerReport)
|
|
420
|
+
} else {
|
|
421
|
+
mapOf("applied" to true, "mode" to "direct", "count" to 0)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
attachCamera(cameraId)
|
|
425
|
+
val rebuilt = compositor
|
|
426
|
+
if (hasOverlay && rebuilt == null) {
|
|
427
|
+
return mapOf("applied" to false, "why" to "gl_setup_failed", "mode" to "direct")
|
|
428
|
+
}
|
|
429
|
+
return mapOf(
|
|
430
|
+
"applied" to true,
|
|
431
|
+
"mode" to if (rebuilt != null) "gl" else "direct",
|
|
432
|
+
"items" to (rebuilt?.lastLayerReport ?: emptyList<Map<String, Any>>()),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
174
435
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Áp RIÊNG lớp chữ. Khác applyOverlay ở một điểm sống còn: cùng-trạng-thái
|
|
438
|
+
* thì CHỈ gọi setTextLayers — tuyệt đối không setLayers lại media (rebuild là
|
|
439
|
+
* restart MediaPlayer của lớp video giữa sóng). Chuyển trạng thái pipeline
|
|
440
|
+
* (0 overlay ↔ có) vẫn phải dựng lại camera như applyOverlay.
|
|
441
|
+
*/
|
|
442
|
+
private suspend fun applyOverlayTexts(): Map<String, Any> {
|
|
443
|
+
if (camera?.isOpen != true) {
|
|
444
|
+
return mapOf("applied" to false, "why" to "no_camera")
|
|
445
|
+
}
|
|
446
|
+
val active = compositor
|
|
447
|
+
if (hasOverlay == (active != null)) {
|
|
448
|
+
return if (active != null) {
|
|
449
|
+
active.setTextLayers(buildTextSpecs())
|
|
450
|
+
mapOf("applied" to true, "mode" to "gl", "items" to active.lastTextReport)
|
|
451
|
+
} else {
|
|
452
|
+
mapOf("applied" to true, "mode" to "direct", "count" to 0)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
attachCamera(cameraId)
|
|
456
|
+
val rebuilt = compositor
|
|
457
|
+
if (hasOverlay && rebuilt == null) {
|
|
458
|
+
return mapOf("applied" to false, "why" to "gl_setup_failed", "mode" to "direct")
|
|
459
|
+
}
|
|
460
|
+
return mapOf(
|
|
461
|
+
"applied" to true,
|
|
462
|
+
"mode" to if (rebuilt != null) "gl" else "direct",
|
|
463
|
+
"items" to (rebuilt?.lastTextReport ?: emptyList<Map<String, Any>>()),
|
|
464
|
+
)
|
|
465
|
+
}
|
|
178
466
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
467
|
+
/** #rrggbb → ARGB; alpha nhân riêng (nền). Sai định dạng → fallback. */
|
|
468
|
+
private fun argbOf(hex: String?, alpha: Double, fallback: Int): Int {
|
|
469
|
+
val h = hex?.trim()?.removePrefix("#") ?: return fallback
|
|
470
|
+
val rgb = h.toIntOrNull(16) ?: return fallback
|
|
471
|
+
if (h.length != 6) return fallback
|
|
472
|
+
val a = (alpha.coerceIn(0.0, 1.0) * 255).toInt()
|
|
473
|
+
return (a shl 24) or rgb
|
|
474
|
+
}
|
|
186
475
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
476
|
+
private fun buildTextSpecs(): List<GlCompositor.TextSpec> =
|
|
477
|
+
overlayTextSpecs.map {
|
|
478
|
+
GlCompositor.TextSpec(
|
|
479
|
+
id = it.id,
|
|
480
|
+
text = it.text,
|
|
481
|
+
x = it.x.toInt(),
|
|
482
|
+
y = it.y.toInt(),
|
|
483
|
+
fontSizePx = it.fontSizePx.toInt(),
|
|
484
|
+
align = it.align,
|
|
485
|
+
bold = it.weight != "regular",
|
|
486
|
+
colorArgb = argbOf(it.color, 1.0, 0xffffffff.toInt()),
|
|
487
|
+
opacity = it.opacity.toFloat().coerceIn(0f, 1f),
|
|
488
|
+
bgArgb = it.bgColor?.let { bg -> argbOf(bg, it.bgOpacity ?: 0.5, 0x8c000000.toInt()) },
|
|
489
|
+
)
|
|
490
|
+
}
|
|
190
491
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
492
|
+
private fun buildLayerSpecs(): List<GlCompositor.LayerSpec> {
|
|
493
|
+
val out = mutableListOf<GlCompositor.LayerSpec>()
|
|
494
|
+
overlayLogoSpec?.let {
|
|
495
|
+
out.add(
|
|
496
|
+
GlCompositor.LayerSpec(
|
|
497
|
+
id = "__logo",
|
|
498
|
+
kind = "image",
|
|
499
|
+
filePath = it.filePath,
|
|
500
|
+
x = it.x.toInt(),
|
|
501
|
+
y = it.y.toInt(),
|
|
502
|
+
width = it.width.toInt(),
|
|
503
|
+
height = it.height.toInt(),
|
|
504
|
+
opacity = it.opacity.toFloat(),
|
|
200
505
|
),
|
|
201
506
|
)
|
|
202
|
-
throw error
|
|
203
507
|
}
|
|
508
|
+
overlayAnimatedSpecs.forEach {
|
|
509
|
+
out.add(
|
|
510
|
+
GlCompositor.LayerSpec(
|
|
511
|
+
id = it.id,
|
|
512
|
+
kind = it.kind,
|
|
513
|
+
filePath = it.filePath,
|
|
514
|
+
x = it.x.toInt(),
|
|
515
|
+
y = it.y.toInt(),
|
|
516
|
+
width = it.width.toInt(),
|
|
517
|
+
height = it.height.toInt(),
|
|
518
|
+
opacity = it.opacity.toFloat(),
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
return out
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Surface preview đến/đi sau khi camera đã mở thì phải dựng lại capture
|
|
527
|
+
* session: Camera2 chốt danh sách target ngay lúc tạo session, không thêm bớt
|
|
528
|
+
* được. Chỉ dựng lại khi surface THỰC SỰ đổi, nếu không sẽ lặp vô hạn.
|
|
529
|
+
*/
|
|
530
|
+
private fun observePreviewSurface() {
|
|
531
|
+
PickleballRtmpPreviewRegistry.observe { surface ->
|
|
532
|
+
if (surface === attachedPreview) return@observe
|
|
533
|
+
// Có compositor thì preview chỉ là một EGLSurface đích — thay tại chỗ,
|
|
534
|
+
// KHÔNG dựng lại camera (camera đang ghi vào SurfaceTexture của GL).
|
|
535
|
+
compositor?.let {
|
|
536
|
+
attachedPreview = surface
|
|
537
|
+
it.setPreviewSurface(surface, captureWidth, captureHeight)
|
|
538
|
+
return@observe
|
|
539
|
+
}
|
|
540
|
+
reattachScope.launch {
|
|
541
|
+
runCatching { attachCamera(cameraId) }
|
|
542
|
+
.onFailure {
|
|
543
|
+
emitStatus(
|
|
544
|
+
mapOf(
|
|
545
|
+
"status" to "error",
|
|
546
|
+
"code" to "camera_reattach_failed",
|
|
547
|
+
"message" to (it.message ?: it.toString()),
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private fun applyStoredControls() {
|
|
556
|
+
val active = camera ?: return
|
|
557
|
+
zoomRatio?.let { active.setZoom(it) }
|
|
558
|
+
exposureBiasEv?.let { active.setExposureBias(it) }
|
|
559
|
+
if (focusLocked) active.setFocusLocked(true)
|
|
560
|
+
if (exposureLocked) active.setExposureLocked(true)
|
|
561
|
+
if (torchEnabled) active.setTorch(true)
|
|
562
|
+
antibandingMode?.let { active.setAntibanding(it) }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// MARK: - RTMP
|
|
566
|
+
|
|
567
|
+
private fun connect(options: ConnectOptions) {
|
|
568
|
+
rtmpUrl = options.url
|
|
569
|
+
videoBitrate = options.videoBitrate
|
|
570
|
+
options.audioBitrate?.let { audioBitrate = it }
|
|
571
|
+
video.setBitrate(options.videoBitrate)
|
|
572
|
+
|
|
573
|
+
// Đóng client cũ TRƯỚC khi tạo cái mới. Gán `rtmp = RtmpClient(...)` bên dưới
|
|
574
|
+
// chỉ thay tham chiếu — client cũ vẫn giữ socket RTMP đang mở, không ai đóng.
|
|
575
|
+
// engine.ts gọi connect() ở cả start() lẫn reconnect(), nên mỗi vòng reconnect
|
|
576
|
+
// rò một socket. Trên iOS lỗi y hệt đã làm MediaMTX nhận 3 kết nối cho một
|
|
577
|
+
// lần publish và giết luôn socket đang sống khi dọn hai cái mồ côi
|
|
578
|
+
// (prod 16/08/2026, phiên chết sau 24 giây).
|
|
579
|
+
rtmp?.let { stale ->
|
|
580
|
+
publishing = false
|
|
581
|
+
stopBandwidthMonitor()
|
|
582
|
+
runCatching { stale.disconnect() }
|
|
583
|
+
}
|
|
584
|
+
rtmp = null
|
|
585
|
+
videoPtsBaseUs = -1
|
|
586
|
+
audioPtsBaseUs = -1
|
|
587
|
+
|
|
588
|
+
emitStatus(mapOf("status" to "connecting"))
|
|
589
|
+
// RtmpClient chỉ mở socket ở connect(); ta giữ nguyên ngữ nghĩa cũ —
|
|
590
|
+
// "connected" = đã sẵn sàng (standby), "publishing" = thật sự đang đẩy.
|
|
591
|
+
rtmp = RtmpClient(
|
|
592
|
+
object : ConnectChecker {
|
|
593
|
+
override fun onConnectionStarted(url: String) = Unit
|
|
594
|
+
|
|
595
|
+
override fun onConnectionSuccess() {
|
|
596
|
+
publishing = true
|
|
597
|
+
startBandwidthMonitor()
|
|
598
|
+
emitStatus(mapOf("status" to "publishing"))
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
override fun onConnectionFailed(reason: String) {
|
|
602
|
+
// RootEncoder dùng CHUNG callback này cho hai chuyện khác hẳn nhau: bắt
|
|
603
|
+
// tay ban đầu thất bại, và socket chết GIỮA lúc đang đẩy (RtmpSender bắn
|
|
604
|
+
// "send error: …Broken pipe" khi mạng rớt). Phải tách ra, vì "error" và
|
|
605
|
+
// "disconnected" đi hai đường hoàn toàn khác ở engine — mất kết nối giữa
|
|
606
|
+
// luồng là chuyện hồi phục được, còn báo "error" thì trước đây engine chỉ
|
|
607
|
+
// ghi lỗi rồi để phiên chết vĩnh viễn (18/08/2026: Android không phát lại
|
|
608
|
+
// được sau khi mất mạng 1-2 giây, iOS hồi được vì HaishinKit báo đúng là
|
|
609
|
+
// disconnected).
|
|
610
|
+
val wasPublishing = publishing
|
|
611
|
+
publishing = false
|
|
612
|
+
stopBandwidthMonitor()
|
|
613
|
+
if (wasPublishing) {
|
|
614
|
+
emitStatus(mapOf("status" to "disconnected", "reason" to "network_loss"))
|
|
615
|
+
} else {
|
|
616
|
+
emitStatus(
|
|
617
|
+
mapOf("status" to "error", "code" to "connect_failed", "message" to reason),
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
override fun onDisconnect() {
|
|
623
|
+
if (publishing) {
|
|
624
|
+
publishing = false
|
|
625
|
+
stopBandwidthMonitor()
|
|
626
|
+
emitStatus(mapOf("status" to "disconnected", "reason" to "network_loss"))
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
override fun onAuthError() {
|
|
631
|
+
publishing = false
|
|
632
|
+
emitStatus(mapOf("status" to "error", "code" to "auth_failed", "message" to "RTMP auth"))
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
override fun onAuthSuccess() = Unit
|
|
636
|
+
},
|
|
637
|
+
)
|
|
638
|
+
connected = true
|
|
639
|
+
emitStatus(mapOf("status" to "connected"))
|
|
640
|
+
if (options.publish) setPublishing(true)
|
|
204
641
|
}
|
|
205
642
|
|
|
206
|
-
private
|
|
207
|
-
val
|
|
643
|
+
private fun setPublishing(enabled: Boolean) {
|
|
644
|
+
val client = rtmp ?: throw RtmpException("chưa kết nối RTMP")
|
|
645
|
+
val url = rtmpUrl ?: throw RtmpException("thiếu URL RTMP")
|
|
208
646
|
if (enabled) {
|
|
209
647
|
if (publishing) return
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
648
|
+
val header = sps ?: throw RtmpException("encoder chưa phát SPS — camera chưa có khung")
|
|
649
|
+
client.setVideoInfo(header.duplicate(), pps?.duplicate(), null)
|
|
650
|
+
if (microphoneEnabled) {
|
|
651
|
+
client.setAudioInfo(AudioEncoder.SAMPLE_RATE, AudioEncoder.IS_STEREO)
|
|
652
|
+
startAudioIfNeeded()
|
|
653
|
+
} else {
|
|
654
|
+
client.setOnlyVideo(true)
|
|
655
|
+
}
|
|
656
|
+
// Xin IDR ngay để luồng bắt đầu bằng keyframe, không chờ tới 2 giây.
|
|
657
|
+
video.requestKeyframe()
|
|
658
|
+
client.connect(url)
|
|
659
|
+
// publishing bật trong onConnectionSuccess — tránh báo "đang phát" khi
|
|
660
|
+
// socket còn chưa bắt tay xong.
|
|
213
661
|
} else {
|
|
214
662
|
if (!publishing) return
|
|
215
|
-
|
|
663
|
+
stopBandwidthMonitor()
|
|
216
664
|
publishing = false
|
|
665
|
+
runCatching { client.disconnect() }
|
|
666
|
+
audio.release()
|
|
217
667
|
emitStatus(mapOf("status" to "connected"))
|
|
218
668
|
}
|
|
219
669
|
}
|
|
220
670
|
|
|
221
|
-
private
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
671
|
+
private fun startAudioIfNeeded() {
|
|
672
|
+
if (!microphoneEnabled) return
|
|
673
|
+
audio.release()
|
|
674
|
+
audio.prepare(audioBitrate)
|
|
675
|
+
audio.start { buffer, info ->
|
|
676
|
+
if (publishing) {
|
|
677
|
+
if (audioPtsBaseUs < 0) audioPtsBaseUs = info.presentationTimeUs
|
|
678
|
+
info.presentationTimeUs -= audioPtsBaseUs
|
|
679
|
+
rtmp?.sendAudio(buffer, info)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private fun closeSession(reason: String) {
|
|
685
|
+
stopBandwidthMonitor()
|
|
225
686
|
publishing = false
|
|
687
|
+
connected = false
|
|
688
|
+
runCatching { rtmp?.disconnect() }
|
|
689
|
+
rtmp = null
|
|
690
|
+
rtmpUrl = null
|
|
691
|
+
audio.release()
|
|
226
692
|
emitStatus(mapOf("status" to "disconnected", "reason" to reason))
|
|
227
693
|
}
|
|
228
694
|
|
|
229
|
-
private
|
|
695
|
+
private fun teardown() {
|
|
230
696
|
closeSession("dispose")
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
697
|
+
camera?.close()
|
|
698
|
+
camera = null
|
|
699
|
+
compositor?.release()
|
|
700
|
+
compositor = null
|
|
701
|
+
video.release()
|
|
702
|
+
audio.release()
|
|
703
|
+
sps = null
|
|
704
|
+
pps = null
|
|
705
|
+
PickleballRtmpPreviewRegistry.observe(null)
|
|
706
|
+
// Camera đã đóng → không còn lý do giữ tiến trình ưu tiên cao, và để lại
|
|
707
|
+
// thông báo thường trực khi không quay là phiền người dùng.
|
|
708
|
+
runCatching { PickleballRtmpService.stop(context) }
|
|
239
709
|
}
|
|
240
710
|
|
|
241
|
-
// MARK: - Điều chỉnh
|
|
242
|
-
|
|
243
711
|
private fun applyVideoBitrate(bitsPerSecond: Int) {
|
|
244
|
-
val setting = session?.stream?.videoSetting ?: return
|
|
245
|
-
val (width, height) = orientedSize()
|
|
246
|
-
setting.bitRate = bitsPerSecond
|
|
247
|
-
// Encoder phải cùng hướng với screen, nếu không khung ra bị bóp méo.
|
|
248
|
-
setting.width = width
|
|
249
|
-
setting.height = height
|
|
250
|
-
// Keyframe 2 giây, khớp ranh giới segment HLS phía MediaMTX.
|
|
251
|
-
setting.IFrameInterval = 2
|
|
252
712
|
videoBitrate = bitsPerSecond
|
|
713
|
+
// Đổi bitrate ĐỘNG, không dựng lại encoder — dựng lại là thủng segment HLS.
|
|
714
|
+
video.setBitrate(bitsPerSecond)
|
|
253
715
|
}
|
|
254
716
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
717
|
+
// MARK: - Băng thông
|
|
718
|
+
|
|
719
|
+
private val currentFps: Int
|
|
720
|
+
get() = lastFpsSample
|
|
721
|
+
|
|
722
|
+
private var lastFpsSample = 0
|
|
723
|
+
private var lastFrameCount = 0L
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Bản HaishinKit cũ phải tự đo byte đẩy ra rồi so với bitrate đích. RtmpClient
|
|
727
|
+
* cho tín hiệu trực tiếp và tốt hơn: `hasCongestion()` nhìn HÀNG ĐỢI CHƯA GỬI
|
|
728
|
+
* ĐƯỢC của sender, nên nghẽn lộ ra trước khi throughput kịp tụt.
|
|
729
|
+
*/
|
|
730
|
+
private fun startBandwidthMonitor() {
|
|
731
|
+
stopBandwidthMonitor()
|
|
732
|
+
bandwidthJob = bandwidthScope.launch {
|
|
733
|
+
var lastAtNanos = System.nanoTime()
|
|
734
|
+
lastFrameCount = video.encodedFrames
|
|
735
|
+
var insufficientStreak = 0
|
|
736
|
+
while (isActive) {
|
|
737
|
+
delay(BANDWIDTH_SAMPLE_MS)
|
|
738
|
+
if (!publishing) break
|
|
739
|
+
if (rtmp?.isStreaming != true) {
|
|
740
|
+
publishing = false
|
|
741
|
+
emitStatus(mapOf("status" to "disconnected", "reason" to "network_loss"))
|
|
742
|
+
break
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
val nowNanos = System.nanoTime()
|
|
746
|
+
val elapsedSec = (nowNanos - lastAtNanos) / 1_000_000_000.0
|
|
747
|
+
val frames = video.encodedFrames
|
|
748
|
+
if (elapsedSec > 0) {
|
|
749
|
+
lastFpsSample = ((frames - lastFrameCount) / elapsedSec).toInt()
|
|
750
|
+
}
|
|
751
|
+
lastFrameCount = frames
|
|
752
|
+
lastAtNanos = nowNanos
|
|
753
|
+
|
|
754
|
+
val congested = runCatching {
|
|
755
|
+
rtmp?.hasCongestion(CONGESTION_PERCENT) ?: false
|
|
756
|
+
}.getOrDefault(false)
|
|
757
|
+
insufficientStreak = if (congested) insufficientStreak + 1 else 0
|
|
758
|
+
sendEvent(
|
|
759
|
+
"onBandwidth",
|
|
760
|
+
mapOf(
|
|
761
|
+
// Cần 2 mẫu xấu liên tiếp mới báo nghẽn — lọc nhiễu burst ngắn.
|
|
762
|
+
"sufficient" to (insufficientStreak < 2),
|
|
763
|
+
"videoBitrate" to videoBitrate,
|
|
764
|
+
),
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
261
768
|
}
|
|
262
769
|
|
|
263
|
-
|
|
770
|
+
private fun stopBandwidthMonitor() {
|
|
771
|
+
bandwidthJob?.cancel()
|
|
772
|
+
bandwidthJob = null
|
|
773
|
+
}
|
|
264
774
|
|
|
265
|
-
|
|
775
|
+
// MARK: - Snapshot
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* PixelCopy đọc thẳng từ surface preview. Bản cũ phải nhờ `screen.readPixels`
|
|
779
|
+
* của HaishinKit; giờ không còn mixer nên đọc trực tiếp, và cái đọc được đúng
|
|
780
|
+
* bằng cái đang được mã hoá vì cả hai cùng nguồn.
|
|
781
|
+
*/
|
|
782
|
+
private suspend fun snapshot(): String {
|
|
783
|
+
// ĐO THẬT trên REDMI Note 15 (20/08/2026), app landscape, máy đặt ngang:
|
|
784
|
+
// buffer=1280x720 view=1920x1080 capture=1280x720 sensor=90 display=90
|
|
785
|
+
// Công thức Camera2 lý thuyết cho (sensor - display) = 0, tức "không cần
|
|
786
|
+
// xoay" — NHƯNG so khớp ảnh chụp với màn hình máy cho thấy ngược lại: bản
|
|
787
|
+
// đã xoay -90 khớp nhất (sai khác 0.78 so với 1.18/1.20 của hai phương án
|
|
788
|
+
// kia). Kết luận: PixelCopy trả BUFFER THÔ, không mang phép xoay mà
|
|
789
|
+
// SurfaceFlinger áp lúc compose lên màn hình. Nên phải tự xoay ở đây, và
|
|
790
|
+
// đừng tin công thức display-rotation cho đường chụp này.
|
|
791
|
+
val view = PickleballRtmpPreviewRegistry.view()
|
|
792
|
+
?: throw RtmpException("chưa có preview — gọi prepare() trước")
|
|
793
|
+
val handler = copyThread?.let { Handler(it.looper) } ?: run {
|
|
794
|
+
val thread = HandlerThread("pickleball-pixelcopy").also { it.start() }
|
|
795
|
+
copyThread = thread
|
|
796
|
+
Handler(thread.looper)
|
|
797
|
+
}
|
|
798
|
+
// Kích thước lấy từ BUFFER, không lấy theo view: PixelCopy SCALE nội dung
|
|
799
|
+
// surface vào bitmap đích, nên cấp theo view (kích thước hiển thị) là để
|
|
800
|
+
// tỉ lệ hiển thị quyết định tỉ lệ ảnh — sai ngay từ gốc.
|
|
801
|
+
val (bufferW, bufferH) = PickleballRtmpPreviewRegistry.bufferSize()
|
|
802
|
+
val srcW = bufferW.takeIf { it > 0 } ?: captureWidth
|
|
803
|
+
val srcH = bufferH.takeIf { it > 0 } ?: captureHeight
|
|
804
|
+
val degrees = sensorOrientationDegrees()
|
|
805
|
+
// ĐẢO CẠNH khi phải xoay 90/270.
|
|
806
|
+
//
|
|
807
|
+
// Buffer thô 1280x720 (ngang) đang CHỨA cảnh nằm nghiêng — cảnh đúng chiều
|
|
808
|
+
// vốn có khung 9:16, bị ép vào khung 16:9 nên đã méo sẵn. Chép vào bitmap
|
|
809
|
+
// ngang là giữ nguyên cái méo đó, xoay xong vẫn méo (đo 20/08/2026: túi
|
|
810
|
+
// chiếm 49% bề ngang ảnh so với 40% trên màn hình máy). Cấp bitmap đảo
|
|
811
|
+
// cạnh thì PixelCopy duỗi ngược lại, xoay xong ra đúng khung 16:9.
|
|
812
|
+
val swap = degrees == 90 || degrees == 270
|
|
813
|
+
val width = if (swap) srcH else srcW
|
|
814
|
+
val height = if (swap) srcW else srcH
|
|
815
|
+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
816
|
+
val ok = withTimeoutOrNull(2_000) {
|
|
817
|
+
suspendCancellableCoroutine<Boolean> { cont ->
|
|
818
|
+
runCatching {
|
|
819
|
+
PixelCopy.request(view, bitmap, { result ->
|
|
820
|
+
if (cont.isActive) cont.resume(result == PixelCopy.SUCCESS)
|
|
821
|
+
}, handler)
|
|
822
|
+
}.onFailure { if (cont.isActive) cont.resume(false) }
|
|
823
|
+
}
|
|
824
|
+
} ?: false
|
|
825
|
+
if (!ok) {
|
|
826
|
+
bitmap.recycle()
|
|
827
|
+
throw RtmpException("không lấy được khung hình (PixelCopy thất bại)")
|
|
828
|
+
}
|
|
829
|
+
// Xoay NGƯỢC hướng cảm biến. Camera2 ghi thẳng vào surface theo hướng cảm
|
|
830
|
+
// biến; phép xoay chỉ được SurfaceFlinger áp lúc HIỂN THỊ, nên màn hình máy
|
|
831
|
+
// nhìn đúng còn bản chép ra thì lệch — đây là lý do chỉ Android dính, iOS
|
|
832
|
+
// lấy pixel buffer đã đúng chiều.
|
|
833
|
+
//
|
|
834
|
+
// 🔴 Ghi chú cũ ở đây từng kết luận "xoay theo SENSOR_ORIENTATION cho kết
|
|
835
|
+
// quả TỆ HƠN, đừng thử lại". Sai ở DẤU chứ không ở ý tưởng: xoay +90 khi
|
|
836
|
+
// cảm biến lệch 90 làm ảnh thành lệch 180 — trông còn tệ hơn ảnh gốc, nên
|
|
837
|
+
// bị hiểu nhầm là hướng sai. Đo trên máy thật 20/08/2026 (REDMI Note 15):
|
|
838
|
+
// ảnh lệch THEO chiều kim đồng hồ → phải xoay NGƯỢC lại đúng chừng đó.
|
|
839
|
+
if (degrees == 0) return encodeJpegBase64(bitmap, 480, 50)
|
|
840
|
+
val matrix = Matrix().apply { postRotate(-degrees.toFloat()) }
|
|
841
|
+
val rotated = runCatching {
|
|
842
|
+
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
843
|
+
}.getOrNull()
|
|
844
|
+
if (rotated == null || rotated === bitmap) return encodeJpegBase64(bitmap, 480, 50)
|
|
845
|
+
bitmap.recycle()
|
|
846
|
+
return encodeJpegBase64(rotated, 480, 50)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Xoay nguồn cho GL compositor — chuẩn tham chiếu là camera SAU (sensor 90,
|
|
851
|
+
* buffer tự nhiên đúng chiều như đường addTarget thẳng đã kiểm field). Camera
|
|
852
|
+
* khác sensor thì bù phần lệch so với 90 (front 270 → 180).
|
|
853
|
+
*/
|
|
854
|
+
private fun cameraRotationFor(id: String): Int {
|
|
855
|
+
val sensor = runCatching {
|
|
856
|
+
(context.getSystemService(Context.CAMERA_SERVICE) as CameraManager)
|
|
857
|
+
.getCameraCharacteristics(id)
|
|
858
|
+
.get(CameraCharacteristics.SENSOR_ORIENTATION)
|
|
859
|
+
}.getOrNull() ?: 90
|
|
860
|
+
return (((sensor - 90) % 360) + 360) % 360
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Độ lệch của cảm biến so với ảnh cần hiển thị (0/90/180/270). Đọc từ
|
|
865
|
+
* CameraCharacteristics thay vì hằng số cứng: máy trước/sau và từng đời máy
|
|
866
|
+
* khác nhau, hằng số 90 đúng với REDMI nhưng không đúng với mọi máy.
|
|
867
|
+
*/
|
|
868
|
+
private fun sensorOrientationDegrees(): Int {
|
|
869
|
+
val value = camera?.characteristics()?.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
|
|
870
|
+
// Chuẩn hoá về [0,360) rồi ép về bội số 90 — giá trị lạ thì coi như không xoay.
|
|
871
|
+
return when (((value % 360) + 360) % 360) {
|
|
872
|
+
90 -> 90
|
|
873
|
+
180 -> 180
|
|
874
|
+
270 -> 270
|
|
875
|
+
else -> 0
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private fun encodeJpegBase64(bitmap: Bitmap, maxWidth: Int, quality: Int): String {
|
|
880
|
+
try {
|
|
881
|
+
val scaled = if (bitmap.width > maxWidth) {
|
|
882
|
+
val height = (bitmap.height.toFloat() * maxWidth / bitmap.width).toInt().coerceAtLeast(1)
|
|
883
|
+
Bitmap.createScaledBitmap(bitmap, maxWidth, height, true)
|
|
884
|
+
} else {
|
|
885
|
+
bitmap
|
|
886
|
+
}
|
|
887
|
+
val stream = ByteArrayOutputStream()
|
|
888
|
+
scaled.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
|
889
|
+
if (scaled !== bitmap) scaled.recycle()
|
|
890
|
+
return Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
|
|
891
|
+
} finally {
|
|
892
|
+
bitmap.recycle()
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// MARK: - Sức khoẻ máy (nhiệt + pin)
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Mức tiết giảm nhiệt và nhiệt độ pin — hai thứ quyết định máy có trụ nổi cả
|
|
900
|
+
* ngày giải hay không.
|
|
901
|
+
*
|
|
902
|
+
* 🔴 Vì sao phải là native: không package Expo nào đọc được hai giá trị này.
|
|
903
|
+
* Hai ngày thực địa 08–09/08/2026 ghi 5.598 mẫu `deviceMetrics` mà `thermal`
|
|
904
|
+
* và `batteryTempC` RỖNG TRẮNG cả hai — nên khi máy tụt pin còn 10% và nghi
|
|
905
|
+
* quá nhiệt thì không có một số liệu nào để đối chiếu.
|
|
906
|
+
*
|
|
907
|
+
* Cả hai đều optional: máy cũ (API < 29) không có thermal, nhánh pin thiếu
|
|
908
|
+
* intent thì bỏ trường đó. Đọc hỏng KHÔNG được phép ném lỗi — collector chạy
|
|
909
|
+
* trong nhịp heartbeat, làm gãy nó là mất luôn mọi chỉ số khác.
|
|
910
|
+
*/
|
|
911
|
+
private fun deviceHealth(): Map<String, Any> {
|
|
912
|
+
val out = mutableMapOf<String, Any>()
|
|
913
|
+
|
|
914
|
+
// THERMAL_STATUS_*: 0 NONE · 1 LIGHT · 2 MODERATE · 3 SEVERE · 4 CRITICAL
|
|
915
|
+
// · 5 EMERGENCY · 6 SHUTDOWN. Dashboard báo "máy quá nóng" từ mức 3.
|
|
916
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
917
|
+
runCatching {
|
|
918
|
+
val power = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
|
919
|
+
power?.currentThermalStatus
|
|
920
|
+
}.getOrNull()?.let { out["thermal"] = it }
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Sticky broadcast: truyền null receiver = đọc giá trị hiện tại rồi thôi,
|
|
924
|
+
// không đăng ký lắng nghe (không có gì phải nhớ gỡ).
|
|
925
|
+
runCatching {
|
|
926
|
+
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
|
927
|
+
// EXTRA_TEMPERATURE tính bằng 1/10 °C; -1 = máy không báo.
|
|
928
|
+
val tenths = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1
|
|
929
|
+
if (tenths > 0) out["batteryTempC"] = tenths / 10.0
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return out
|
|
933
|
+
}
|
|
266
934
|
|
|
267
935
|
/**
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
936
|
+
* Lý do các lần tiến trình chết gần đây, mới nhất trước — đọc từ
|
|
937
|
+
* ActivityManager.getHistoricalProcessExitReasons (Android 11+, hệ thống giữ
|
|
938
|
+
* vài ngày). Đây là cách DUY NHẤT biết máy gác sân chết vì gì mà không cắm
|
|
939
|
+
* cáp: 22/08/2026 camera sân 4 im 21 phút giữa giải, phải cắm adb mới thấy
|
|
940
|
+
* `reason=PACKAGE_UPDATED · stop com.google.android.webview` (Play Store tự
|
|
941
|
+
* cập nhật WebView, Android giết mọi app đang load WebView — app RN nào cũng
|
|
942
|
+
* dính qua CookieManager). App gửi danh sách này lên heartbeat, dashboard hiện.
|
|
272
943
|
*
|
|
273
|
-
*
|
|
944
|
+
* Mã reason theo ApplicationExitInfo.REASON_* — đổi sang tên ở đây để JS và
|
|
945
|
+
* dashboard không phải giữ bảng mã riêng. Máy < API 30 trả rỗng, không ném.
|
|
274
946
|
*/
|
|
947
|
+
private fun processExitReasons(max: Int): List<Map<String, Any>> {
|
|
948
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return emptyList()
|
|
949
|
+
val limit = max.coerceIn(1, 16)
|
|
950
|
+
return runCatching {
|
|
951
|
+
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
|
952
|
+
manager.getHistoricalProcessExitReasons(context.packageName, 0, limit).map { info ->
|
|
953
|
+
val out = mutableMapOf<String, Any>(
|
|
954
|
+
"at" to info.timestamp,
|
|
955
|
+
"pid" to info.pid,
|
|
956
|
+
"reason" to info.reason,
|
|
957
|
+
"reasonName" to exitReasonName(info.reason),
|
|
958
|
+
"importance" to info.importance,
|
|
959
|
+
"status" to info.status,
|
|
960
|
+
)
|
|
961
|
+
info.description?.let { out["description"] = it }
|
|
962
|
+
out
|
|
963
|
+
}
|
|
964
|
+
}.getOrDefault(emptyList())
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private fun exitReasonName(reason: Int): String = when (reason) {
|
|
968
|
+
ApplicationExitInfo.REASON_EXIT_SELF -> "EXIT_SELF"
|
|
969
|
+
ApplicationExitInfo.REASON_SIGNALED -> "SIGNALED"
|
|
970
|
+
ApplicationExitInfo.REASON_LOW_MEMORY -> "LOW_MEMORY"
|
|
971
|
+
ApplicationExitInfo.REASON_CRASH -> "CRASH"
|
|
972
|
+
ApplicationExitInfo.REASON_CRASH_NATIVE -> "CRASH_NATIVE"
|
|
973
|
+
ApplicationExitInfo.REASON_ANR -> "ANR"
|
|
974
|
+
ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> "INITIALIZATION_FAILURE"
|
|
975
|
+
ApplicationExitInfo.REASON_PERMISSION_CHANGE -> "PERMISSION_CHANGE"
|
|
976
|
+
ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> "EXCESSIVE_RESOURCE_USAGE"
|
|
977
|
+
ApplicationExitInfo.REASON_USER_REQUESTED -> "USER_REQUESTED"
|
|
978
|
+
ApplicationExitInfo.REASON_USER_STOPPED -> "USER_STOPPED"
|
|
979
|
+
ApplicationExitInfo.REASON_DEPENDENCY_DIED -> "DEPENDENCY_DIED"
|
|
980
|
+
ApplicationExitInfo.REASON_OTHER -> "OTHER"
|
|
981
|
+
ApplicationExitInfo.REASON_FREEZER -> "FREEZER"
|
|
982
|
+
ApplicationExitInfo.REASON_PACKAGE_STATE_CHANGE -> "PACKAGE_STATE_CHANGE"
|
|
983
|
+
ApplicationExitInfo.REASON_PACKAGE_UPDATED -> "PACKAGE_UPDATED"
|
|
984
|
+
else -> "UNKNOWN"
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// MARK: - Camera list
|
|
988
|
+
|
|
989
|
+
private data class LensInfo(val id: String, val label: String, val front: Boolean)
|
|
990
|
+
|
|
991
|
+
private fun isFront(id: String) = availableCameras().firstOrNull { it.id == id }?.front == true
|
|
992
|
+
|
|
993
|
+
private fun frontCameraId() = availableCameras().firstOrNull { it.front }?.id
|
|
994
|
+
|
|
275
995
|
private fun availableCameras(): List<LensInfo> {
|
|
276
996
|
val manager = context.getSystemService(Context.CAMERA_SERVICE) as? CameraManager
|
|
277
997
|
?: return emptyList()
|
|
@@ -305,16 +1025,66 @@ class PickleballRtmpNativeModule : Module() {
|
|
|
305
1025
|
} + front.map { LensInfo(it.first, "Front Camera", true) }
|
|
306
1026
|
}
|
|
307
1027
|
|
|
308
|
-
|
|
1028
|
+
/** Bounds cho UI — sheet điều khiển ẨN control nào supported:false. */
|
|
1029
|
+
private fun cameraCapabilities(): Map<String, Any> {
|
|
1030
|
+
val chars = camera?.characteristics() ?: runCatching {
|
|
1031
|
+
(context.getSystemService(Context.CAMERA_SERVICE) as CameraManager)
|
|
1032
|
+
.getCameraCharacteristics(cameraId)
|
|
1033
|
+
}.getOrNull()
|
|
1034
|
+
val open = camera?.isOpen == true
|
|
309
1035
|
|
|
310
|
-
|
|
1036
|
+
val zoomRange = chars?.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE)
|
|
1037
|
+
val maxDigital = chars?.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f
|
|
1038
|
+
val evRange = chars?.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)
|
|
1039
|
+
val evStep = chars?.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_STEP)?.toFloat() ?: 0f
|
|
1040
|
+
val afModes = chars?.get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES)
|
|
1041
|
+
val hasFlash = chars?.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false
|
|
1042
|
+
|
|
1043
|
+
// Lọc theo đúng tiêu chí setFpsRange() dùng lúc chạy: phải có AE range phủ
|
|
1044
|
+
// được fps đó, nếu không camera sẽ chạy một tốc độ khác với encoder.
|
|
1045
|
+
val aeRanges = chars?.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)
|
|
1046
|
+
val frameRates = OFFERED_FRAME_RATES.filter { fps ->
|
|
1047
|
+
aeRanges?.any { fps >= it.lower && fps <= it.upper } == true
|
|
1048
|
+
}.ifEmpty { listOf(30) }
|
|
1049
|
+
|
|
1050
|
+
return mapOf(
|
|
1051
|
+
"supportedFrameRates" to frameRates,
|
|
1052
|
+
"zoom" to mapOf(
|
|
1053
|
+
"supported" to open,
|
|
1054
|
+
"min" to (zoomRange?.lower?.toDouble() ?: 1.0),
|
|
1055
|
+
"max" to (zoomRange?.upper?.toDouble() ?: maxDigital.toDouble()),
|
|
1056
|
+
),
|
|
1057
|
+
"exposureBias" to mapOf(
|
|
1058
|
+
"supported" to (open && evRange != null && evStep > 0f),
|
|
1059
|
+
"min" to ((evRange?.lower ?: 0) * evStep).toDouble(),
|
|
1060
|
+
"max" to ((evRange?.upper ?: 0) * evStep).toDouble(),
|
|
1061
|
+
),
|
|
1062
|
+
"focusLock" to mapOf(
|
|
1063
|
+
"supported" to (open && afModes?.contains(1) == true),
|
|
1064
|
+
),
|
|
1065
|
+
"exposureLock" to mapOf("supported" to open),
|
|
1066
|
+
"torch" to mapOf("supported" to (open && hasFlash)),
|
|
1067
|
+
)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private fun cloneBuffer(source: ByteBuffer): ByteBuffer {
|
|
1071
|
+
val copy = ByteBuffer.allocateDirect(source.remaining())
|
|
1072
|
+
copy.put(source.duplicate())
|
|
1073
|
+
copy.flip()
|
|
1074
|
+
return copy
|
|
1075
|
+
}
|
|
311
1076
|
|
|
312
1077
|
private fun emitStatus(payload: Map<String, Any?>) {
|
|
313
|
-
sendEvent("onStatusChanged", payload)
|
|
1078
|
+
runCatching { sendEvent("onStatusChanged", payload) }
|
|
314
1079
|
}
|
|
315
1080
|
|
|
316
1081
|
private companion object {
|
|
317
1082
|
const val BACK_DEFAULT_ID = "0"
|
|
1083
|
+
const val BANDWIDTH_SAMPLE_MS = 2_000L
|
|
1084
|
+
/** Hàng đợi sender dùng quá % này thì coi là nghẽn (mặc định lib là 20). */
|
|
1085
|
+
const val CONGESTION_PERCENT = 20f
|
|
1086
|
+
/** fps mà UI được phép chào — phải khớp `offeredFrameRates` bên iOS. */
|
|
1087
|
+
val OFFERED_FRAME_RATES = listOf(24, 30, 60)
|
|
318
1088
|
}
|
|
319
1089
|
}
|
|
320
1090
|
|
|
@@ -323,8 +1093,14 @@ class PrepareOptions : Record {
|
|
|
323
1093
|
|
|
324
1094
|
@Field val frameRate: Int = 30
|
|
325
1095
|
|
|
326
|
-
/**
|
|
1096
|
+
/**
|
|
1097
|
+
* Giữ lại cho tương thích API, nhưng Android SAU khi bỏ tầng GL chỉ phát
|
|
1098
|
+
* khung ngang — xoay sang 9:16 cần đúng bước xoay ảnh vừa được loại bỏ.
|
|
1099
|
+
*/
|
|
327
1100
|
@Field val landscape: Boolean = true
|
|
1101
|
+
|
|
1102
|
+
/** Camera mặc định người dùng chọn; null = giữ BACK_DEFAULT_ID. */
|
|
1103
|
+
@Field val cameraId: String? = null
|
|
328
1104
|
}
|
|
329
1105
|
|
|
330
1106
|
class ConnectOptions : Record {
|
|
@@ -334,7 +1110,71 @@ class ConnectOptions : Record {
|
|
|
334
1110
|
|
|
335
1111
|
@Field val audioBitrate: Int? = null
|
|
336
1112
|
|
|
1113
|
+
/** false = chuẩn bị sẵn nhưng chưa phát (standby). */
|
|
337
1114
|
@Field val publish: Boolean = true
|
|
338
1115
|
}
|
|
339
1116
|
|
|
1117
|
+
/** Ô đặt overlay tĩnh — PIXEL của khung encode, gốc trên-trái (JS tính bằng shared). */
|
|
1118
|
+
class OverlayLogoOptions : Record {
|
|
1119
|
+
@Field val filePath: String = ""
|
|
1120
|
+
|
|
1121
|
+
@Field val x: Double = 0.0
|
|
1122
|
+
|
|
1123
|
+
@Field val y: Double = 0.0
|
|
1124
|
+
|
|
1125
|
+
@Field val width: Double = 0.0
|
|
1126
|
+
|
|
1127
|
+
@Field val height: Double = 0.0
|
|
1128
|
+
|
|
1129
|
+
@Field val opacity: Double = 1.0
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Một lớp overlay ĐỘNG (gif/video) — cùng hệ pixel, thêm id + kind. */
|
|
1133
|
+
class OverlayAnimatedOptions : Record {
|
|
1134
|
+
@Field val id: String = ""
|
|
1135
|
+
|
|
1136
|
+
@Field val kind: String = ""
|
|
1137
|
+
|
|
1138
|
+
@Field val filePath: String = ""
|
|
1139
|
+
|
|
1140
|
+
@Field val x: Double = 0.0
|
|
1141
|
+
|
|
1142
|
+
@Field val y: Double = 0.0
|
|
1143
|
+
|
|
1144
|
+
@Field val width: Double = 0.0
|
|
1145
|
+
|
|
1146
|
+
@Field val height: Double = 0.0
|
|
1147
|
+
|
|
1148
|
+
@Field val opacity: Double = 1.0
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Một lớp CHỮ đã điền sẵn — JS gửi CHUỖI CHỐT + hình học pixel (mỏ neo x theo
|
|
1153
|
+
* align); native chỉ vẽ. Nền là field PHẲNG (bgColor/bgOpacity) — không có
|
|
1154
|
+
* bgColor = không nền.
|
|
1155
|
+
*/
|
|
1156
|
+
class OverlayTextOptions : Record {
|
|
1157
|
+
@Field val id: String = ""
|
|
1158
|
+
|
|
1159
|
+
@Field val text: String = ""
|
|
1160
|
+
|
|
1161
|
+
@Field val x: Double = 0.0
|
|
1162
|
+
|
|
1163
|
+
@Field val y: Double = 0.0
|
|
1164
|
+
|
|
1165
|
+
@Field val fontSizePx: Double = 48.0
|
|
1166
|
+
|
|
1167
|
+
@Field val align: String = "left"
|
|
1168
|
+
|
|
1169
|
+
@Field val weight: String = "bold"
|
|
1170
|
+
|
|
1171
|
+
@Field val color: String = "#ffffff"
|
|
1172
|
+
|
|
1173
|
+
@Field val opacity: Double = 1.0
|
|
1174
|
+
|
|
1175
|
+
@Field val bgColor: String? = null
|
|
1176
|
+
|
|
1177
|
+
@Field val bgOpacity: Double? = null
|
|
1178
|
+
}
|
|
1179
|
+
|
|
340
1180
|
class RtmpException(detail: String) : CodedException("ERR_PICKLEBALL_RTMP", detail, null)
|