@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.
@@ -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.graphics.Rect
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.net.Uri
8
- import com.haishinkit.media.MediaMixer
9
- import com.haishinkit.media.source.AudioRecordSource
10
- import com.haishinkit.media.source.Camera2Source
11
- import com.haishinkit.stream.StreamSession
12
- import com.haishinkit.rtmp.RtmpStreamSessionFactory
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
- * Bản Android của cầu nối deliveryMode="device-rtmp" song song với
22
- * ios/PickleballRtmpNativeModule.swift.
44
+ * Cầu nối deliveryMode="device-rtmp" cho Android.
23
45
  *
24
- * API Kotlin của HaishinKit KHÁC hẳn bản Swift dù cùng tên dự án:
25
- * MediaMixer nhận Context, attachVideo/attachAudio đặt track TRƯỚC nguồn, và
26
- * session build() ném lỗi thay trả optional. Đừng suy từ file Swift sang.
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 mixer: MediaMixer? = null
30
- private var session: StreamSession? = null
31
- private var videoSource: Camera2Source? = null
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
- /** Luôn giữ dạng NGANG (cạnh dài trước); hướng thật tính ở orientedSize(). */
38
- private var videoLongEdge = 1920
39
- private var videoShortEdge = 1080
40
- private var landscape = true
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
- mixer?.attachAudio(0, if (enabled) AudioRecordSource(context) else null)
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 (session?.isConnected ?: false),
262
+ "isConnected" to (rtmp?.isStreaming ?: false),
100
263
  "isPublishing" to publishing,
101
264
  "videoBitrate" to videoBitrate,
102
- "currentFps" to 0,
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
- // OnDestroy không phải coroutine — chỉ dọn phần đồng bộ được.
112
- mixer?.stopRunning()
113
- PickleballRtmpPreviewRegistry.setMixer(null)
114
- mixer = null
115
- session = null
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
- videoLongEdge = 1280
127
- videoShortEdge = 720
305
+ captureWidth = 1280
306
+ captureHeight = 720
128
307
  } else {
129
- videoLongEdge = 1920
130
- videoShortEdge = 1080
308
+ captureWidth = 1920
309
+ captureHeight = 1080
131
310
  }
132
- landscape = options.landscape
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
- if (microphoneEnabled) {
140
- active.attachAudio(0, AudioRecordSource(context))
141
- }
142
- active.startRunning()
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()
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
- * Đặt khung ảnh theo hướng người dùng chọn.
334
+ * Mở camera với encoder surface, kèm preview surface NẾU đã có.
152
335
  *
153
- * `Screen.create()` đặt frame cứng kích thước ngang mặc định, không hề biết
154
- * người dùng chọn 9:16 không set lại khung dọc bị bóp/cắt và
155
- * Camera2Source cũng chọn sai kích thước capture.
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, previewTrack chỉ SAU khi prepare() xong
338
+ * chờ đây 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 applyScreenGeometry() {
167
- val active = mixer ?: return
168
- val (width, height) = orientedSize()
169
- active.screen.frame = Rect(0, 0, width, height)
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
- private suspend fun connect(options: ConnectOptions) {
173
- val active = mixer ?: throw RtmpException("gọi prepare() trước khi connect()")
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
- // Cùng cái bẫy như iOS: registry factory khởi tạo RỖNG, thiếu bước này thì
176
- // build() không nhận ra scheme rtmp:// URL hoàn toàn hợp lệ.
177
- StreamSession.Builder.registerFactory(RtmpStreamSessionFactory)
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
- emitStatus(mapOf("status" to "connecting"))
180
- try {
181
- val built = StreamSession.Builder(context, Uri.parse(options.url))
182
- .setMode(StreamSession.Mode.PUBLISH)
183
- .build()
184
- session = built
185
- active.registerOutput(built.stream)
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
- videoBitrate = options.videoBitrate
188
- applyVideoBitrate(options.videoBitrate)
189
- options.audioBitrate?.let { built.stream.audioSetting.bitRate = it }
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
- emitStatus(mapOf("status" to "connected"))
192
- if (options.publish) setPublishing(true)
193
- } catch (error: Throwable) {
194
- session = null
195
- emitStatus(
196
- mapOf(
197
- "status" to "error",
198
- "code" to "connect_failed",
199
- "message" to (error.message ?: error.toString()),
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 suspend fun setPublishing(enabled: Boolean) {
207
- val active = session ?: throw RtmpException("chưa kết nối RTMP")
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
- active.connect().getOrThrow()
211
- publishing = true
212
- emitStatus(mapOf("status" to "publishing"))
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
- active.close()
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 suspend fun closeSession(reason: String) {
222
- val active = session ?: return
223
- active.close()
224
- session = null
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 suspend fun teardown() {
695
+ private fun teardown() {
230
696
  closeSession("dispose")
231
- mixer?.let {
232
- it.stopRunning()
233
- it.attachAudio(0, null)
234
- it.attachVideo(0, null)
235
- }
236
- PickleballRtmpPreviewRegistry.setMixer(null)
237
- videoSource = null
238
- mixer = null
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
- private suspend fun attachCamera(id: String) {
256
- val active = mixer ?: return
257
- val source = Camera2Source(context, id)
258
- active.attachVideo(0, source)
259
- videoSource = source
260
- cameraId = id
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
- // MARK: - Liệt kê ống kính
770
+ private fun stopBandwidthMonitor() {
771
+ bandwidthJob?.cancel()
772
+ bandwidthJob = null
773
+ }
264
774
 
265
- private data class LensInfo(val id: String, val label: String, val front: Boolean)
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
- * Android không `localizedName` như AVFoundation, nên nhãn phải TỰ DỰNG
269
- * đúng dạng tiếng Anh UI dò (`/ultra\s*wide/i`, `/telephoto/i` trong
270
- * livestream-screen). Trả nhãn dạng khác thì cả ba ống kính đều hiện "1x"
271
- * đúng lỗi đã gặp trên iOS.
936
+ * 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 cách DUY NHẤT biết máy gác sân chết 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
- * Phân loại theo tiêu cự so với ống chính: ngắn hơn = siêu rộng, dài hơn = tele.
944
+ * reason theo ApplicationExitInfo.REASON_* đổi sang tên đây để JS
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
- private fun isFront(id: String) = availableCameras().firstOrNull { it.id == id }?.front == true
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
- private fun frontCameraId() = availableCameras().firstOrNull { it.front }?.id
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
- /** Khung người dùng chọn: true = 16:9 ngang, false = 9:16 dọc. */
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)