@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,61 +1,157 @@
1
1
  package expo.modules.pickleballrtmp
2
2
 
3
3
  import android.content.Context
4
+ import android.view.Surface
5
+ import android.view.SurfaceHolder
6
+ import android.view.SurfaceView
4
7
  import android.view.ViewGroup
5
- import com.haishinkit.media.MediaMixer
6
- import com.haishinkit.view.HkSurfaceView
7
8
  import expo.modules.kotlin.AppContext
8
9
  import expo.modules.kotlin.views.ExpoView
9
10
 
10
11
  /**
11
- * Nối view preview với mixer bất kể bên nào sẵn sàng trước.
12
+ * Preview = SurfaceView THUẦN. Camera2 ghi thẳng vào surface này.
12
13
  *
13
- * React mount view JS gọi prepare() theo thứ tự không đoán trước được; giữ
14
- * tham chiếu đây để bên đến sau tự bắt được bên đến trước. Thiếu cơ chế này
15
- * thì màn hình đen camera đã chạy đúng lỗi đã gặp trên iOS.
14
+ * Trước đây đây HkSurfaceView nhận khung đã qua mixer GL của HaishinKit
15
+ * chính tầng đó gây răng cưa (xem ghi chú kiến trúc trong
16
+ * PickleballRtmpNativeModule). Giờ không còn texture trung gian nào: ISP đổ
17
+ * khung vào surface này và vào surface của encoder song song, trong cùng một
18
+ * capture session.
19
+ *
20
+ * Hệ quả phụ đáng giá: cái thấy trên màn hình đúng bằng cái được mã hoá, vì cả
21
+ * hai lấy từ cùng một nguồn chứ không phải cùng một bản compose.
16
22
  */
17
23
  object PickleballRtmpPreviewRegistry {
18
- private var mixer: MediaMixer? = null
19
- private var view: HkSurfaceView? = null
24
+ private var surface: Surface? = null
25
+ private var listener: ((Surface?) -> Unit)? = null
26
+
27
+ /** Kích thước buffer preview — đặt bằng đúng cỡ capture để không phải scale. */
28
+ private var width = 1920
29
+ private var height = 1080
30
+ private var holder: SurfaceHolder? = null
31
+
32
+ @Synchronized
33
+ fun setBufferSize(w: Int, h: Int) {
34
+ width = w
35
+ height = h
36
+ holder?.setFixedSize(w, h)
37
+ }
20
38
 
21
39
  @Synchronized
22
- fun setMixer(value: MediaMixer?) {
23
- mixer = value
24
- bind()
40
+ fun attach(target: SurfaceHolder?) {
41
+ holder = target
42
+ target?.setFixedSize(width, height)
25
43
  }
26
44
 
27
45
  @Synchronized
28
- fun setView(value: HkSurfaceView?) {
29
- view?.let { previous -> mixer?.unregisterOutput(previous) }
30
- view = value
31
- bind()
46
+ fun publish(value: Surface?) {
47
+ surface = value
48
+ listener?.invoke(value)
32
49
  }
33
50
 
34
- private fun bind() {
35
- val currentMixer = mixer ?: return
36
- val currentView = view ?: return
37
- currentMixer.registerOutput(currentView)
51
+ /**
52
+ * Module đăng để biết surface sẵn sàng. View mount và prepare() gọi theo
53
+ * thứ tự không đoán trước được, nên bên đến sau phải bắt được bên đến trước —
54
+ * gọi callback ngay nếu surface đã có.
55
+ */
56
+ @Synchronized
57
+ fun observe(callback: ((Surface?) -> Unit)?) {
58
+ listener = callback
59
+ callback?.invoke(surface)
38
60
  }
61
+
62
+ @Synchronized
63
+ fun current(): Surface? = surface
64
+
65
+ /**
66
+ * SurfaceView đang hiển thị — snapshot PHẢI dùng bản PixelCopy nhận
67
+ * SurfaceView chứ không phải bản nhận Surface: bản Surface đọc buffer THÔ,
68
+ * còn camera ghi theo hướng cảm biến (SENSOR_ORIENTATION=90) và phép xoay do
69
+ * SurfaceFlinger áp lúc hiển thị. Đọc buffer thô = ảnh lệch 90°.
70
+ */
71
+ @Synchronized
72
+ fun view(): SurfaceView? = viewRef
73
+
74
+ private var viewRef: SurfaceView? = null
75
+
76
+ @Synchronized
77
+ fun setView(value: SurfaceView?) {
78
+ viewRef = value
79
+ }
80
+
81
+ @Synchronized
82
+ fun bufferAspect(): Float = width.toFloat() / height.toFloat()
83
+
84
+ /**
85
+ * Kích thước THẬT của buffer preview (đúng cỡ capture, VD 1920x1080).
86
+ *
87
+ * snapshot() phải cấp bitmap đúng cỡ này: PixelCopy SCALE nội dung surface
88
+ * vào bitmap đích, nên cấp theo kích thước VIEW (đang dọc theo màn hình máy)
89
+ * là ép khung ngang vào khung dọc — ảnh méo, và xoay xong vẫn méo.
90
+ */
91
+ @Synchronized
92
+ fun bufferSize(): Pair<Int, Int> = width to height
39
93
  }
40
94
 
41
95
  class PickleballRtmpPreviewView(
42
96
  context: Context,
43
97
  appContext: AppContext,
44
98
  ) : ExpoView(context, appContext) {
45
- private val surface = HkSurfaceView(context).also {
46
- it.layoutParams = ViewGroup.LayoutParams(
99
+ private val surfaceView = SurfaceView(context).also { view ->
100
+ view.layoutParams = ViewGroup.LayoutParams(
47
101
  ViewGroup.LayoutParams.MATCH_PARENT,
48
102
  ViewGroup.LayoutParams.MATCH_PARENT,
49
103
  )
50
- addView(it)
104
+ view.holder.addCallback(
105
+ object : SurfaceHolder.Callback {
106
+ override fun surfaceCreated(holder: SurfaceHolder) {
107
+ PickleballRtmpPreviewRegistry.attach(holder)
108
+ }
109
+
110
+ override fun surfaceChanged(holder: SurfaceHolder, f: Int, w: Int, h: Int) {
111
+ PickleballRtmpPreviewRegistry.publish(holder.surface)
112
+ }
113
+
114
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
115
+ PickleballRtmpPreviewRegistry.attach(null)
116
+ PickleballRtmpPreviewRegistry.publish(null)
117
+ }
118
+ },
119
+ )
120
+ addView(view)
51
121
  }
52
122
 
53
123
  init {
54
- PickleballRtmpPreviewRegistry.setView(surface)
124
+ // Giữ màn hình sáng suốt thời gian preview hiển thị — kể cả lúc standby chờ
125
+ // lệnh publish. Keep-awake phía JS chỉ bật trong phiên (enterSession).
126
+ keepScreenOn = true
127
+ PickleballRtmpPreviewRegistry.setView(surfaceView)
55
128
  }
56
129
 
57
130
  override fun onDetachedFromWindow() {
58
131
  PickleballRtmpPreviewRegistry.setView(null)
59
132
  super.onDetachedFromWindow()
60
133
  }
134
+
135
+ /**
136
+ * Letterbox thay vì kéo giãn.
137
+ *
138
+ * SurfaceView để MATCH_PARENT thì SurfaceFlinger kéo buffer 16:9 cho đầy màn
139
+ * 21.9:9 của máy — giãn ngang 1.246x, méo hình. Tự đo và căn giữa theo đúng
140
+ * tỉ lệ buffer để cái nhìn thấy = cái được mã hoá.
141
+ */
142
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
143
+ val width = r - l
144
+ val height = b - t
145
+ if (width <= 0 || height <= 0) return
146
+ val aspect = PickleballRtmpPreviewRegistry.bufferAspect()
147
+ var childWidth = width
148
+ var childHeight = (width / aspect).toInt()
149
+ if (childHeight > height) {
150
+ childHeight = height
151
+ childWidth = (height * aspect).toInt()
152
+ }
153
+ val left = (width - childWidth) / 2
154
+ val top = (height - childHeight) / 2
155
+ surfaceView.layout(left, top, left + childWidth, top + childHeight)
156
+ }
61
157
  }
@@ -0,0 +1,126 @@
1
+ package expo.modules.pickleballrtmp
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.app.Service
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.content.pm.ServiceInfo
11
+ import android.os.Build
12
+ import android.os.IBinder
13
+ import androidx.core.app.NotificationCompat
14
+
15
+ /**
16
+ * Foreground service GIỮ TIẾN TRÌNH SỐNG suốt thời gian camera đang mở.
17
+ *
18
+ * Vì sao cần: máy này là camera đặt cố định ở sân, không phải app cầm tay. Khoá
19
+ * màn hình hay chuyển sang app khác một lúc là Android (HyperOS của Xiaomi đặc
20
+ * biệt hung) giết tiến trình — đo thực tế: cho ra nền 10 GIÂY là PID đã đổi
21
+ * 5155 → 6070. Tiến trình chết thì phiên mồ côi, mở lại app phải tạo phiên mới:
22
+ * đúng triệu chứng "thoát app vào lại là mất phiên".
23
+ *
24
+ * Service này CỐ Ý không làm gì ngoài việc tồn tại. Toàn bộ camera/encoder/RTMP
25
+ * vẫn do PickleballRtmpNativeModule sở hữu — service chỉ nâng mức ưu tiên tiến
26
+ * trình và khai báo hai loại FGS `camera`+`microphone` để Android 14+ cho phép
27
+ * app tiếp tục dùng camera và mic khi không ở tiền cảnh.
28
+ *
29
+ * START_NOT_STICKY chứ KHÔNG phải START_STICKY: nếu tiến trình vẫn chết, khởi
30
+ * động lại mỗi service mà không có React state đi kèm chỉ tạo ra một cái vỏ giữ
31
+ * thông báo vĩnh viễn, không giữ được gì.
32
+ */
33
+ class PickleballRtmpService : Service() {
34
+ override fun onBind(intent: Intent?): IBinder? = null
35
+
36
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
37
+ if (intent?.action == ACTION_STOP) {
38
+ stopSelf()
39
+ return START_NOT_STICKY
40
+ }
41
+ startInForeground()
42
+ return START_NOT_STICKY
43
+ }
44
+
45
+ private fun startInForeground() {
46
+ ensureChannel()
47
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
48
+ .setContentTitle(appLabel())
49
+ .setContentText("Camera đang hoạt động")
50
+ .setSmallIcon(android.R.drawable.presence_video_online)
51
+ // Ongoing + LOW: không kêu, không rung, người dùng không vuốt bỏ được.
52
+ .setOngoing(true)
53
+ .setPriority(NotificationCompat.PRIORITY_LOW)
54
+ .setContentIntent(launchIntent())
55
+ .build()
56
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
57
+ startForeground(
58
+ NOTIFICATION_ID,
59
+ notification,
60
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or
61
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
62
+ )
63
+ } else {
64
+ startForeground(NOTIFICATION_ID, notification)
65
+ }
66
+ }
67
+
68
+ /** Bấm vào thông báo → quay lại app, không mở activity mới. */
69
+ private fun launchIntent(): PendingIntent? {
70
+ val intent = packageManager.getLaunchIntentForPackage(packageName) ?: return null
71
+ return PendingIntent.getActivity(
72
+ this,
73
+ 0,
74
+ intent,
75
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
76
+ )
77
+ }
78
+
79
+ private fun appLabel(): String =
80
+ runCatching { applicationInfo.loadLabel(packageManager).toString() }
81
+ .getOrDefault("Camera")
82
+
83
+ private fun ensureChannel() {
84
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
85
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
86
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
87
+ manager.createNotificationChannel(
88
+ NotificationChannel(
89
+ CHANNEL_ID,
90
+ "Camera trực tiếp",
91
+ NotificationManager.IMPORTANCE_LOW,
92
+ ).apply {
93
+ description = "Giữ camera chạy khi app ở nền"
94
+ setShowBadge(false)
95
+ },
96
+ )
97
+ }
98
+
99
+ companion object {
100
+ private const val NOTIFICATION_ID = 0x9C41
101
+ private const val CHANNEL_ID = "pickleball-rtmp-camera"
102
+ private const val ACTION_STOP = "expo.modules.pickleballrtmp.STOP"
103
+
104
+ /**
105
+ * PHẢI gọi khi app còn ở tiền cảnh. Từ Android 12 (S), khởi động foreground
106
+ * service từ nền ném ForegroundServiceStartNotAllowedException — nên điểm gọi
107
+ * là prepare(), lúc người dùng vừa mở màn camera.
108
+ */
109
+ fun start(context: Context) {
110
+ val intent = Intent(context, PickleballRtmpService::class.java)
111
+ runCatching {
112
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
113
+ context.startForegroundService(intent)
114
+ } else {
115
+ context.startService(intent)
116
+ }
117
+ }
118
+ }
119
+
120
+ fun stop(context: Context) {
121
+ runCatching {
122
+ context.stopService(Intent(context, PickleballRtmpService::class.java))
123
+ }
124
+ }
125
+ }
126
+ }