@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,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
|
-
*
|
|
12
|
+
* Preview = SurfaceView THUẦN. Camera2 ghi thẳng vào surface này.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Trước đây đây là 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
|
|
19
|
-
private var
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
fun attach(target: SurfaceHolder?) {
|
|
41
|
+
holder = target
|
|
42
|
+
target?.setFixedSize(width, height)
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
@Synchronized
|
|
28
|
-
fun
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
bind()
|
|
46
|
+
fun publish(value: Surface?) {
|
|
47
|
+
surface = value
|
|
48
|
+
listener?.invoke(value)
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Module đăng ký để 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
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|