@pickleball/rtmp-native 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pickleball Live contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,30 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'expo.modules.pickleballrtmp'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace 'expo.modules.pickleballrtmp'
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName '0.1.0'
14
+ }
15
+ lintOptions { abortOnError false }
16
+ }
17
+
18
+ repositories {
19
+ // HaishinKit.kt phát hành qua JitPack, không có trên Maven Central.
20
+ maven { url 'https://jitpack.io' }
21
+ }
22
+
23
+ dependencies {
24
+ implementation 'androidx.core:core-ktx:1.15.0'
25
+ // Bản Kotlin của HaishinKit. API KHÁC bản Swift: MediaMixer(context),
26
+ // StreamSession.Builder(...).build(), mixer.registerOutput(...).
27
+ implementation 'com.github.HaishinKit.HaishinKit~kt:haishinkit:0.18.2'
28
+ // Module rtmp tách riêng — thiếu nó thì không có RtmpStreamSessionFactory.
29
+ implementation 'com.github.HaishinKit.HaishinKit~kt:rtmp:0.18.2'
30
+ }
@@ -0,0 +1,5 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.CAMERA" />
3
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
4
+ <uses-permission android:name="android.permission.INTERNET" />
5
+ </manifest>
@@ -0,0 +1,340 @@
1
+ package expo.modules.pickleballrtmp
2
+
3
+ import android.content.Context
4
+ import android.graphics.Rect
5
+ import android.hardware.camera2.CameraCharacteristics
6
+ 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
13
+ import expo.modules.kotlin.functions.Coroutine
14
+ import expo.modules.kotlin.modules.Module
15
+ import expo.modules.kotlin.modules.ModuleDefinition
16
+ import expo.modules.kotlin.records.Field
17
+ import expo.modules.kotlin.records.Record
18
+ import expo.modules.kotlin.exception.CodedException
19
+
20
+ /**
21
+ * Bản Android của cầu nối deliveryMode="device-rtmp" — song song với
22
+ * ios/PickleballRtmpNativeModule.swift.
23
+ *
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 vì trả optional. Đừng suy từ file Swift sang.
27
+ */
28
+ class PickleballRtmpNativeModule : Module() {
29
+ private var mixer: MediaMixer? = null
30
+ private var session: StreamSession? = null
31
+ private var videoSource: Camera2Source? = null
32
+ /** Phải khớp mặc định của engine (`isCameraFront: false`) như bên iOS. */
33
+ private var cameraId: String = BACK_DEFAULT_ID
34
+ private var microphoneEnabled = true
35
+ private var publishing = false
36
+ 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
41
+
42
+ private val context: Context
43
+ get() = appContext.reactContext ?: throw RtmpException("thiếu Android context")
44
+
45
+ override fun definition() = ModuleDefinition {
46
+ Name("PickleballRtmpNative")
47
+ Events("onStatusChanged", "onBandwidth")
48
+
49
+ View(PickleballRtmpPreviewView::class) {}
50
+
51
+ AsyncFunction("prepare") Coroutine { options: PrepareOptions ->
52
+ prepare(options)
53
+ }
54
+
55
+ AsyncFunction("connect") Coroutine { options: ConnectOptions ->
56
+ connect(options)
57
+ }
58
+
59
+ AsyncFunction("setPublishing") Coroutine { enabled: Boolean ->
60
+ setPublishing(enabled)
61
+ }
62
+
63
+ AsyncFunction("disconnect") Coroutine { ->
64
+ closeSession("client")
65
+ }
66
+
67
+ AsyncFunction("switchCamera") Coroutine { ->
68
+ val target = if (isFront(cameraId)) BACK_DEFAULT_ID else frontCameraId()
69
+ attachCamera(target ?: BACK_DEFAULT_ID)
70
+ }
71
+
72
+ Function("listCameras") {
73
+ availableCameras().map {
74
+ mapOf(
75
+ "deviceId" to it.id,
76
+ "label" to it.label,
77
+ "facing" to if (it.front) "front" else "environment",
78
+ )
79
+ }
80
+ }
81
+
82
+ AsyncFunction("selectCamera") Coroutine { deviceId: String, _: String ->
83
+ attachCamera(deviceId)
84
+ }
85
+
86
+ AsyncFunction("setMicrophoneEnabled") Coroutine { enabled: Boolean ->
87
+ microphoneEnabled = enabled
88
+ // 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)
90
+ Unit
91
+ }
92
+
93
+ AsyncFunction("setVideoBitrate") Coroutine { bitsPerSecond: Int ->
94
+ applyVideoBitrate(bitsPerSecond)
95
+ }
96
+
97
+ Function("getStats") {
98
+ mapOf(
99
+ "isConnected" to (session?.isConnected ?: false),
100
+ "isPublishing" to publishing,
101
+ "videoBitrate" to videoBitrate,
102
+ "currentFps" to 0,
103
+ )
104
+ }
105
+
106
+ AsyncFunction("dispose") Coroutine { ->
107
+ teardown()
108
+ }
109
+
110
+ 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
116
+ }
117
+ }
118
+
119
+ // MARK: - Vòng đời
120
+
121
+ private suspend fun prepare(options: PrepareOptions) {
122
+ val active = mixer ?: MediaMixer(context).also { mixer = it }
123
+ PickleballRtmpPreviewRegistry.setMixer(active)
124
+
125
+ if (options.quality == 720) {
126
+ videoLongEdge = 1280
127
+ videoShortEdge = 720
128
+ } else {
129
+ videoLongEdge = 1920
130
+ videoShortEdge = 1080
131
+ }
132
+ landscape = options.landscape
133
+
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
+ attachCamera(cameraId)
139
+ if (microphoneEnabled) {
140
+ active.attachAudio(0, AudioRecordSource(context))
141
+ }
142
+ active.startRunning()
143
+ emitStatus(mapOf("status" to "idle"))
144
+ }
145
+
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
+ /**
151
+ * Đặt khung ảnh theo hướng người dùng chọn.
152
+ *
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 là 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.
165
+ */
166
+ private fun applyScreenGeometry() {
167
+ val active = mixer ?: return
168
+ val (width, height) = orientedSize()
169
+ active.screen.frame = Rect(0, 0, width, height)
170
+ }
171
+
172
+ private suspend fun connect(options: ConnectOptions) {
173
+ val active = mixer ?: throw RtmpException("gọi prepare() trước khi connect()")
174
+
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:// dù URL hoàn toàn hợp lệ.
177
+ StreamSession.Builder.registerFactory(RtmpStreamSessionFactory)
178
+
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)
186
+
187
+ videoBitrate = options.videoBitrate
188
+ applyVideoBitrate(options.videoBitrate)
189
+ options.audioBitrate?.let { built.stream.audioSetting.bitRate = it }
190
+
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()),
200
+ ),
201
+ )
202
+ throw error
203
+ }
204
+ }
205
+
206
+ private suspend fun setPublishing(enabled: Boolean) {
207
+ val active = session ?: throw RtmpException("chưa kết nối RTMP")
208
+ if (enabled) {
209
+ if (publishing) return
210
+ active.connect().getOrThrow()
211
+ publishing = true
212
+ emitStatus(mapOf("status" to "publishing"))
213
+ } else {
214
+ if (!publishing) return
215
+ active.close()
216
+ publishing = false
217
+ emitStatus(mapOf("status" to "connected"))
218
+ }
219
+ }
220
+
221
+ private suspend fun closeSession(reason: String) {
222
+ val active = session ?: return
223
+ active.close()
224
+ session = null
225
+ publishing = false
226
+ emitStatus(mapOf("status" to "disconnected", "reason" to reason))
227
+ }
228
+
229
+ private suspend fun teardown() {
230
+ 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
239
+ }
240
+
241
+ // MARK: - Điều chỉnh
242
+
243
+ 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
+ videoBitrate = bitsPerSecond
253
+ }
254
+
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
261
+ }
262
+
263
+ // MARK: - Liệt kê ống kính
264
+
265
+ private data class LensInfo(val id: String, val label: String, val front: Boolean)
266
+
267
+ /**
268
+ * Android không có `localizedName` như AVFoundation, nên nhãn phải TỰ DỰNG
269
+ * đúng dạng tiếng Anh mà 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.
272
+ *
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.
274
+ */
275
+ private fun availableCameras(): List<LensInfo> {
276
+ val manager = context.getSystemService(Context.CAMERA_SERVICE) as? CameraManager
277
+ ?: return emptyList()
278
+ val entries = manager.cameraIdList.mapNotNull { id ->
279
+ runCatching {
280
+ val chars = manager.getCameraCharacteristics(id)
281
+ val capabilities = chars.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)
282
+ // Bỏ camera depth/logic phụ: chúng không quay được video thường.
283
+ val usable = capabilities?.contains(
284
+ CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE,
285
+ ) ?: false
286
+ if (!usable) return@runCatching null
287
+ val focal = chars.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
288
+ ?.minOrNull() ?: 0f
289
+ val front = chars.get(CameraCharacteristics.LENS_FACING) ==
290
+ CameraCharacteristics.LENS_FACING_FRONT
291
+ Triple(id, focal, front)
292
+ }.getOrNull()
293
+ }.filterNotNull()
294
+
295
+ val (front, back) = entries.partition { it.third }
296
+ // Ống chính = tiêu cự trung vị của nhóm sau; so với nó để gọi tên hai ống kia.
297
+ val mainFocal = back.map { it.second }.sorted().getOrNull(back.size / 2) ?: 0f
298
+ return back.map { (id, focal, _) ->
299
+ val label = when {
300
+ focal < mainFocal * 0.8f -> "Back Ultra Wide Camera"
301
+ focal > mainFocal * 1.2f -> "Back Telephoto Camera"
302
+ else -> "Back Camera"
303
+ }
304
+ LensInfo(id, label, false)
305
+ } + front.map { LensInfo(it.first, "Front Camera", true) }
306
+ }
307
+
308
+ private fun isFront(id: String) = availableCameras().firstOrNull { it.id == id }?.front == true
309
+
310
+ private fun frontCameraId() = availableCameras().firstOrNull { it.front }?.id
311
+
312
+ private fun emitStatus(payload: Map<String, Any?>) {
313
+ sendEvent("onStatusChanged", payload)
314
+ }
315
+
316
+ private companion object {
317
+ const val BACK_DEFAULT_ID = "0"
318
+ }
319
+ }
320
+
321
+ class PrepareOptions : Record {
322
+ @Field val quality: Int = 1080
323
+
324
+ @Field val frameRate: Int = 30
325
+
326
+ /** Khung người dùng chọn: true = 16:9 ngang, false = 9:16 dọc. */
327
+ @Field val landscape: Boolean = true
328
+ }
329
+
330
+ class ConnectOptions : Record {
331
+ @Field val url: String = ""
332
+
333
+ @Field val videoBitrate: Int = 6_000_000
334
+
335
+ @Field val audioBitrate: Int? = null
336
+
337
+ @Field val publish: Boolean = true
338
+ }
339
+
340
+ class RtmpException(detail: String) : CodedException("ERR_PICKLEBALL_RTMP", detail, null)
@@ -0,0 +1,61 @@
1
+ package expo.modules.pickleballrtmp
2
+
3
+ import android.content.Context
4
+ import android.view.ViewGroup
5
+ import com.haishinkit.media.MediaMixer
6
+ import com.haishinkit.view.HkSurfaceView
7
+ import expo.modules.kotlin.AppContext
8
+ import expo.modules.kotlin.views.ExpoView
9
+
10
+ /**
11
+ * Nối view preview với mixer bất kể bên nào sẵn sàng trước.
12
+ *
13
+ * React mount view và 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 dù camera đã chạy — đúng lỗi đã gặp trên iOS.
16
+ */
17
+ object PickleballRtmpPreviewRegistry {
18
+ private var mixer: MediaMixer? = null
19
+ private var view: HkSurfaceView? = null
20
+
21
+ @Synchronized
22
+ fun setMixer(value: MediaMixer?) {
23
+ mixer = value
24
+ bind()
25
+ }
26
+
27
+ @Synchronized
28
+ fun setView(value: HkSurfaceView?) {
29
+ view?.let { previous -> mixer?.unregisterOutput(previous) }
30
+ view = value
31
+ bind()
32
+ }
33
+
34
+ private fun bind() {
35
+ val currentMixer = mixer ?: return
36
+ val currentView = view ?: return
37
+ currentMixer.registerOutput(currentView)
38
+ }
39
+ }
40
+
41
+ class PickleballRtmpPreviewView(
42
+ context: Context,
43
+ appContext: AppContext,
44
+ ) : ExpoView(context, appContext) {
45
+ private val surface = HkSurfaceView(context).also {
46
+ it.layoutParams = ViewGroup.LayoutParams(
47
+ ViewGroup.LayoutParams.MATCH_PARENT,
48
+ ViewGroup.LayoutParams.MATCH_PARENT,
49
+ )
50
+ addView(it)
51
+ }
52
+
53
+ init {
54
+ PickleballRtmpPreviewRegistry.setView(surface)
55
+ }
56
+
57
+ override fun onDetachedFromWindow() {
58
+ PickleballRtmpPreviewRegistry.setView(null)
59
+ super.onDetachedFromWindow()
60
+ }
61
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "platforms": [
3
+ "apple",
4
+ "android"
5
+ ],
6
+ "apple": {
7
+ "modules": [
8
+ "PickleballRtmpNativeModule"
9
+ ]
10
+ },
11
+ "android": {
12
+ "modules": [
13
+ "expo.modules.pickleballrtmp.PickleballRtmpNativeModule"
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,17 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = 'PickleballRtmpNative'
3
+ s.version = '0.1.0'
4
+ s.summary = 'Hardware-encoded RTMP publisher'
5
+ s.description = 'Expo bridge over HaishinKit for the device-rtmp delivery mode.'
6
+ s.author = 'Pickleball Live contributors'
7
+ s.homepage = 'https://github.com/Cabinfood/livestream-pickleball'
8
+ s.license = { :type => 'MIT', :file => '../LICENSE' }
9
+ s.platform = :ios, '16.4'
10
+ s.source = { :http => "https://registry.npmjs.org/@pickleball/rtmp-native/-/rtmp-native-#{s.version}.tgz" }
11
+ s.static_framework = true
12
+ s.dependency 'ExpoModulesCore'
13
+ # HaishinKit 2.x: API actor-based (MediaMixer/Session). Bản CocoaPods mới nhất
14
+ # là 2.0.9 — ghim minor để không nhảy sang API khác lúc pod install.
15
+ s.dependency 'HaishinKit', '~> 2.0.9'
16
+ s.source_files = '**/*.swift'
17
+ end
@@ -0,0 +1,359 @@
1
+ import AVFoundation
2
+ import ExpoModulesCore
3
+ import HaishinKit
4
+ import UIKit
5
+
6
+ /// Cầu nối Expo cho mode deliveryMode="device-rtmp".
7
+ ///
8
+ /// Thiết bị mã hoá H.264 BẰNG PHẦN CỨNG một lần rồi đẩy thẳng RTMP — server chỉ
9
+ /// remux sang HLS. Đây là lý do mode này tốn $0 transcode, khác với đường
10
+ /// LiveKit egress (giải mã → dựng lại trong Chrome → mã hoá lại).
11
+ ///
12
+ /// Viết theo API HaishinKit 2.0.9 (actor-based: MediaMixer / Session).
13
+ public final class PickleballRtmpNativeModule: Module {
14
+ private var mixer: MediaMixer?
15
+ private var session: (any Session)?
16
+ // PHẢI khớp mặc định của engine (`isCameraFront: false`) và LiveKitRoomAdapter.
17
+ // Lệch một chỗ là nhãn UI và hình thực tế ngược nhau vĩnh viễn, và
18
+ // switchCamera() càng làm lệch thêm.
19
+ private var cameraPosition: AVCaptureDevice.Position = .back
20
+ private var microphoneEnabled = true
21
+ private var publishing = false
22
+ private var videoBitrate = 6_000_000
23
+ private var videoSize = CGSize(width: 1920, height: 1080)
24
+ /// SessionBuilderFactory KHÔNG có sẵn factory nào — phải tự đăng ký, nếu không
25
+ /// build() ném Error.notFound (error 1). Đăng ký một lần cho cả vòng đời app.
26
+ private static var sessionFactoriesRegistered = false
27
+ private var orientationObserver: NSObjectProtocol?
28
+
29
+ public func definition() -> ModuleDefinition {
30
+ Name("PickleballRtmpNative")
31
+ Events("onStatusChanged", "onBandwidth")
32
+
33
+ // View hiển thị camera. Thiếu nó thì màn preview tối đen dù camera đã mở.
34
+ View(PickleballRtmpPreviewView.self) {}
35
+
36
+ AsyncFunction("prepare") { (options: PrepareOptions) in
37
+ try await self.prepare(options)
38
+ }
39
+
40
+ AsyncFunction("connect") { (options: ConnectOptions) in
41
+ try await self.connect(options)
42
+ }
43
+
44
+ AsyncFunction("setPublishing") { (enabled: Bool) in
45
+ try await self.setPublishing(enabled)
46
+ }
47
+
48
+ AsyncFunction("disconnect") {
49
+ await self.closeSession(reason: "client")
50
+ }
51
+
52
+ AsyncFunction("switchCamera") {
53
+ try await self.switchCamera()
54
+ }
55
+
56
+ Function("listCameras") {
57
+ self.availableCameras().map { device in
58
+ [
59
+ "deviceId": device.uniqueID,
60
+ "label": Self.lensLabel(for: device),
61
+ // "environment" chứ không phải "back" — khớp cách đặt tên của WebRTC
62
+ // mà engine và mode LiveKit đang dùng.
63
+ "facing": device.position == .front ? "front" : "environment",
64
+ ]
65
+ }
66
+ }
67
+
68
+ AsyncFunction("selectCamera") { (deviceId: String, facing: String) in
69
+ try await self.selectCamera(deviceId: deviceId, facing: facing)
70
+ }
71
+
72
+ AsyncFunction("setMicrophoneEnabled") { (enabled: Bool) in
73
+ self.microphoneEnabled = enabled
74
+ // Gỡ hẳn thiết bị audio thay vì chỉ mute: mic tắt thì không encode luôn.
75
+ try await self.mixer?.attachAudio(enabled ? AVCaptureDevice.default(for: .audio) : nil)
76
+ }
77
+
78
+ AsyncFunction("setVideoBitrate") { (bitsPerSecond: Int) in
79
+ await self.applyVideoBitrate(bitsPerSecond)
80
+ }
81
+
82
+ Function("getStats") {
83
+ [
84
+ "isConnected": self.session != nil,
85
+ "isPublishing": self.publishing,
86
+ "videoBitrate": self.videoBitrate,
87
+ "currentFps": 0,
88
+ ] as [String: Any]
89
+ }
90
+
91
+ AsyncFunction("dispose") {
92
+ await self.teardown()
93
+ }
94
+
95
+ OnDestroy {
96
+ let mixer = self.mixer
97
+ let session = self.session
98
+ self.mixer = nil
99
+ self.session = nil
100
+ Task {
101
+ try? await session?.close()
102
+ await mixer?.stopRunning()
103
+ }
104
+ }
105
+ }
106
+
107
+ // MARK: - Vòng đời
108
+
109
+ private func prepare(_ options: PrepareOptions) async throws {
110
+ if mixer == nil {
111
+ // useManualCapture: tự quản vòng đời capture để camera không bị mở/đóng
112
+ // ngoài ý muốn giữa buổi live 10 tiếng.
113
+ mixer = MediaMixer(
114
+ multiCamSessionEnabled: false,
115
+ multiTrackAudioMixingEnabled: false,
116
+ useManualCapture: true
117
+ )
118
+ }
119
+ guard let mixer else { return }
120
+ // Cho view preview bám vào mixer (view có thể đã mount từ trước).
121
+ RtmpPreviewRegistry.shared.setMixer(mixer)
122
+
123
+ videoSize = options.quality == 720
124
+ ? CGSize(width: 1280, height: 720)
125
+ : CGSize(width: 1920, height: 1080)
126
+
127
+ try await mixer.attachVideo(
128
+ AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: cameraPosition),
129
+ track: 0
130
+ ) { unit in
131
+ // Lần gắn đầu cũng phải lật gương như switchCamera(), nếu không khởi động
132
+ // bằng camera trước sẽ hiện ngược so với mode LiveKit.
133
+ unit.isVideoMirrored = self.cameraPosition == .front
134
+ }
135
+ if microphoneEnabled {
136
+ try await mixer.attachAudio(AVCaptureDevice.default(for: .audio))
137
+ }
138
+ await applyVideoOrientation()
139
+ observeOrientation()
140
+ await mixer.startRunning()
141
+ emitStatus(["status": "idle"])
142
+ }
143
+
144
+ private func connect(_ options: ConnectOptions) async throws {
145
+ guard let mixer else {
146
+ throw RtmpException("gọi prepare() trước khi connect()")
147
+ }
148
+ guard let url = URL(string: options.url) else {
149
+ throw RtmpException("URL RTMP không hợp lệ")
150
+ }
151
+
152
+ await Self.registerSessionFactories()
153
+
154
+ emitStatus(["status": "connecting"])
155
+ do {
156
+ // build() trả optional: URL sai scheme (không phải rtmp/srt) thì trả nil
157
+ // chứ không ném lỗi.
158
+ guard let built = try await SessionBuilderFactory.shared.make(url).build() else {
159
+ throw RtmpException("không tạo được phiên cho URL này — scheme phải là rtmp://")
160
+ }
161
+ self.session = built
162
+ // Session và HKStream đều là actor nên mọi truy cập đều phải await.
163
+ let stream = await built.stream
164
+ await mixer.addOutput(stream)
165
+
166
+ videoBitrate = options.videoBitrate
167
+ await applyVideoBitrate(options.videoBitrate)
168
+ await applyAudioBitrate(options.audioBitrate)
169
+
170
+ emitStatus(["status": "connected"])
171
+ if options.publish {
172
+ try await setPublishing(true)
173
+ }
174
+ } catch {
175
+ self.session = nil
176
+ emitStatus([
177
+ "status": "error",
178
+ "code": "connect_failed",
179
+ "message": String(describing: error),
180
+ ])
181
+ throw error
182
+ }
183
+ }
184
+
185
+ /// `SessionBuilderFactory.shared` khởi tạo RỖNG. Thiếu bước này thì mọi
186
+ /// build() đều ném `Error.notFound` dù URL rtmp:// hoàn toàn hợp lệ.
187
+ private static func registerSessionFactories() async {
188
+ guard !sessionFactoriesRegistered else { return }
189
+ sessionFactoriesRegistered = true
190
+ await SessionBuilderFactory.shared.register(RTMPSessionFactory())
191
+ }
192
+
193
+ private func setPublishing(_ enabled: Bool) async throws {
194
+ guard let session else {
195
+ throw RtmpException("chưa kết nối RTMP")
196
+ }
197
+ if enabled {
198
+ guard !publishing else { return }
199
+ try await session.connect(.ingest)
200
+ publishing = true
201
+ emitStatus(["status": "publishing"])
202
+ } else {
203
+ guard publishing else { return }
204
+ try await session.close()
205
+ publishing = false
206
+ emitStatus(["status": "connected"])
207
+ }
208
+ }
209
+
210
+ private func closeSession(reason: String) async {
211
+ guard let session else { return }
212
+ try? await session.close()
213
+ self.session = nil
214
+ publishing = false
215
+ emitStatus(["status": "disconnected", "reason": reason])
216
+ }
217
+
218
+ private func teardown() async {
219
+ stopObservingOrientation()
220
+ await closeSession(reason: "dispose")
221
+ if let mixer {
222
+ await mixer.stopRunning()
223
+ try? await mixer.attachAudio(nil)
224
+ try? await mixer.attachVideo(nil, track: 0)
225
+ }
226
+ RtmpPreviewRegistry.shared.setMixer(nil)
227
+ mixer = nil
228
+ }
229
+
230
+ // MARK: - Điều chỉnh
231
+
232
+ /// Hạ/nâng bitrate thay cho BWE của WebRTC. Mạng yếu mà giữ nguyên bitrate thì
233
+ /// hàng đợi RTMP phình ra và tràn bộ nhớ.
234
+ private func applyVideoBitrate(_ bitsPerSecond: Int) async {
235
+ guard let session else { return }
236
+ let stream = await session.stream
237
+ var settings = await stream.videoSettings
238
+ settings.bitRate = bitsPerSecond
239
+ settings.videoSize = videoSize
240
+ // Keyframe 2 giây: khớp ranh giới segment HLS phía server, nếu không segment
241
+ // sẽ không cắt được đúng chỗ và người xem bị giật.
242
+ settings.maxKeyFrameIntervalDuration = 2
243
+ await stream.setVideoSettings(settings)
244
+ videoBitrate = bitsPerSecond
245
+ }
246
+
247
+ private func applyAudioBitrate(_ bitsPerSecond: Int?) async {
248
+ guard let session, let bitsPerSecond else { return }
249
+ let stream = await session.stream
250
+ var settings = await stream.audioSettings
251
+ settings.bitRate = bitsPerSecond
252
+ await stream.setAudioSettings(settings)
253
+ }
254
+
255
+ /// Không gọi hàm này thì HaishinKit giữ hướng mặc định và KHÔNG xoay theo máy
256
+ /// — video ra bị nằm ngang/dọc sai so với mode LiveKit (WebRTC tự xử lý xoay).
257
+ private func applyVideoOrientation() async {
258
+ guard let mixer else { return }
259
+ let deviceOrientation = await MainActor.run { UIDevice.current.orientation }
260
+ // faceUp/faceDown/unknown trả nil — giữ nguyên hướng cũ thay vì lật bừa.
261
+ guard let videoOrientation = DeviceUtil.videoOrientation(by: deviceOrientation) else {
262
+ return
263
+ }
264
+ await mixer.setVideoOrientation(videoOrientation)
265
+ }
266
+
267
+ private func observeOrientation() {
268
+ guard orientationObserver == nil else { return }
269
+ orientationObserver = NotificationCenter.default.addObserver(
270
+ forName: UIDevice.orientationDidChangeNotification,
271
+ object: nil,
272
+ queue: .main
273
+ ) { [weak self] _ in
274
+ Task { await self?.applyVideoOrientation() }
275
+ }
276
+ }
277
+
278
+ private func stopObservingOrientation() {
279
+ guard let orientationObserver else { return }
280
+ NotificationCenter.default.removeObserver(orientationObserver)
281
+ self.orientationObserver = nil
282
+ }
283
+
284
+ /// Liệt kê mọi ống kính vật lý: siêu rộng (0.5x), chính (1x), tele.
285
+ /// Thiếu hàm này thì UI không có gì để hiện và người dùng kẹt ở ống kính mặc định.
286
+ private func availableCameras() -> [AVCaptureDevice] {
287
+ AVCaptureDevice.DiscoverySession(
288
+ deviceTypes: [
289
+ .builtInUltraWideCamera,
290
+ .builtInWideAngleCamera,
291
+ .builtInTelephotoCamera,
292
+ ],
293
+ mediaType: .video,
294
+ position: .unspecified
295
+ ).devices
296
+ }
297
+
298
+ /// Dùng `localizedName` của AVFoundation ("Back Ultra Wide Camera",
299
+ /// "Back Telephoto Camera", ...) — KHÔNG tự đặt nhãn tiếng Việt.
300
+ ///
301
+ /// UI nhận diện ống kính bằng regex trên nhãn tiếng Anh (livestream-screen:
302
+ /// /ultra\s*wide/i, /telephoto/i) và mặc định về "1x" khi không khớp. Trả nhãn
303
+ /// khác dạng thì cả ba ống kính đều hiện "1x". Mode LiveKit lấy nhãn từ
304
+ /// WebRTC cũng chính là localizedName, nên dùng nó thì hai mode đồng nhất.
305
+ private static func lensLabel(for device: AVCaptureDevice) -> String {
306
+ device.localizedName
307
+ }
308
+
309
+ private func selectCamera(deviceId: String, facing: String) async throws {
310
+ guard let mixer else { return }
311
+ guard let device = availableCameras().first(where: { $0.uniqueID == deviceId }) else {
312
+ throw RtmpException("không tìm thấy ống kính \(deviceId)")
313
+ }
314
+ cameraPosition = device.position == .front ? .front : .back
315
+ let mirrored = cameraPosition == .front
316
+ try await mixer.attachVideo(device, track: 0) { unit in
317
+ unit.isVideoMirrored = mirrored
318
+ }
319
+ }
320
+
321
+ private func switchCamera() async throws {
322
+ guard let mixer else { return }
323
+ let next: AVCaptureDevice.Position = cameraPosition == .back ? .front : .back
324
+ try await mixer.attachVideo(
325
+ AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: next),
326
+ track: 0
327
+ ) { unit in
328
+ unit.isVideoMirrored = next == .front
329
+ }
330
+ cameraPosition = next
331
+ }
332
+
333
+ private func emitStatus(_ payload: [String: Any]) {
334
+ sendEvent("onStatusChanged", payload)
335
+ }
336
+ }
337
+
338
+ // MARK: - Records
339
+
340
+ internal struct PrepareOptions: Record {
341
+ @Field var quality: Int = 1080
342
+ @Field var frameRate: Int = 30
343
+ }
344
+
345
+ internal struct ConnectOptions: Record {
346
+ @Field var url: String = ""
347
+ @Field var videoBitrate: Int = 6_000_000
348
+ @Field var audioBitrate: Int?
349
+ @Field var publish: Bool = true
350
+ }
351
+
352
+ internal final class RtmpException: Exception {
353
+ private let detail: String
354
+ init(_ detail: String) {
355
+ self.detail = detail
356
+ super.init()
357
+ }
358
+ override var reason: String { detail }
359
+ }
@@ -0,0 +1,74 @@
1
+ import AVFoundation
2
+ import ExpoModulesCore
3
+ import HaishinKit
4
+ import UIKit
5
+
6
+ /**
7
+ Giữ `MediaMixer` dùng chung giữa module (capture + publish) và view (hiển thị).
8
+
9
+ Chỉ có một camera nên một mixer là đủ. View có thể mount TRƯỚC khi module gọi
10
+ prepare(), hoặc mount lại sau khi màn hình remount — nên phải gắn được cả hai
11
+ chiều, không giả định thứ tự.
12
+ */
13
+ final class RtmpPreviewRegistry {
14
+ static let shared = RtmpPreviewRegistry()
15
+
16
+ private var mixer: MediaMixer?
17
+ private let views = NSHashTable<MTHKView>.weakObjects()
18
+
19
+ /// Module gọi khi tạo/huỷ mixer; gắn lại mọi view đang chờ.
20
+ func setMixer(_ next: MediaMixer?) {
21
+ mixer = next
22
+ guard let next else { return }
23
+ for view in views.allObjects {
24
+ attach(view, to: next)
25
+ }
26
+ }
27
+
28
+ func register(_ view: MTHKView) {
29
+ views.add(view)
30
+ if let mixer {
31
+ attach(view, to: mixer)
32
+ }
33
+ }
34
+
35
+ func unregister(_ view: MTHKView) {
36
+ views.remove(view)
37
+ guard let mixer else { return }
38
+ Task { await mixer.removeOutput(view) }
39
+ }
40
+
41
+ private func attach(_ view: MTHKView, to mixer: MediaMixer) {
42
+ Task { await mixer.addOutput(view) }
43
+ }
44
+ }
45
+
46
+ /**
47
+ Hiển thị camera cho mode device-rtmp.
48
+
49
+ Không có view này thì màn hình preview TỐI ĐEN: HaishinKit vẫn mở camera và
50
+ encode bình thường, nhưng không có đích nào để vẽ khung hình lên.
51
+
52
+ `MTHKView` đã conform `MediaMixerOutput` nên chỉ cần addOutput vào mixer là
53
+ nhận được frame — không phải dựng pipeline hiển thị riêng.
54
+ */
55
+ public final class PickleballRtmpPreviewView: ExpoView {
56
+ private let hkView = MTHKView(frame: .zero)
57
+
58
+ public required init(appContext: AppContext? = nil) {
59
+ super.init(appContext: appContext)
60
+ // Lấp đầy khung và cắt bớt phần thừa — khớp objectFit="cover" của bản LiveKit.
61
+ hkView.videoGravity = .resizeAspectFill
62
+ addSubview(hkView)
63
+ RtmpPreviewRegistry.shared.register(hkView)
64
+ }
65
+
66
+ deinit {
67
+ RtmpPreviewRegistry.shared.unregister(hkView)
68
+ }
69
+
70
+ public override func layoutSubviews() {
71
+ super.layoutSubviews()
72
+ hkView.frame = bounds
73
+ }
74
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@pickleball/rtmp-native",
3
+ "version": "0.1.0",
4
+ "description": "Hardware-encoded RTMP publisher for Pickleball Live (device-rtmp delivery mode)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Cabinfood/livestream-pickleball.git",
9
+ "directory": "packages/pickleball-rtmp-native"
10
+ },
11
+ "homepage": "https://github.com/Cabinfood/livestream-pickleball#readme",
12
+ "bugs": "https://github.com/Cabinfood/livestream-pickleball/issues",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "types": "src/index.ts",
19
+ "files": [
20
+ "ios",
21
+ "android/src",
22
+ "android/build.gradle",
23
+ "src",
24
+ "expo-module.config.json",
25
+ "LICENSE"
26
+ ],
27
+ "peerDependencies": {
28
+ "expo": ">=56 <58",
29
+ "react": ">=19 <20",
30
+ "react-native": ">=0.85 <0.87"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "~19.2.0",
34
+ "expo": "57.0.4",
35
+ "react": "19.2.0",
36
+ "react-native": "0.86.0",
37
+ "typescript": "^5.8.0"
38
+ },
39
+ "scripts": {
40
+ "test": "node --test test/*.test.mjs",
41
+ "typecheck": "tsc --noEmit",
42
+ "build": "tsc --noEmit"
43
+ }
44
+ }
@@ -0,0 +1,59 @@
1
+ /** Trạng thái đường RTMP — map thẳng sang RoomEvent của expo-sdk engine. */
2
+ export type NativeRtmpStatus =
3
+ | { readonly status: "idle" | "connecting" | "connected" | "publishing" }
4
+ | { readonly status: "disconnected"; readonly reason?: string }
5
+ | { readonly status: "error"; readonly code: string; readonly message: string };
6
+
7
+ /**
8
+ * Phản hồi băng thông từ tầng RTMP — thay cho BWE của WebRTC.
9
+ *
10
+ * Mạng yếu mà không hạ bitrate thì hàng đợi phình ra và tràn bộ nhớ; đây là
11
+ * tín hiệu để engine hạ chất lượng thay vì để dồn ứ.
12
+ */
13
+ export interface NativeRtmpBandwidth {
14
+ /** false = encoder đang đẩy nhanh hơn đường truyền chịu được. */
15
+ readonly sufficient: boolean;
16
+ /** Bitrate video encoder đang dùng thật (bit/giây). */
17
+ readonly videoBitrate: number;
18
+ }
19
+
20
+ export interface NativeRtmpStats {
21
+ readonly isConnected: boolean;
22
+ readonly isPublishing: boolean;
23
+ readonly videoBitrate: number;
24
+ readonly currentFps: number;
25
+ }
26
+
27
+ export interface RtmpPrepareOptions {
28
+ readonly quality: 720 | 1080;
29
+ readonly frameRate?: number;
30
+ /**
31
+ * Khung người dùng chọn: true = 16:9 ngang, false = 9:16 dọc.
32
+ *
33
+ * LiveKit không cần trường này vì WebRTC gắn metadata xoay vào từng frame;
34
+ * HaishinKit nướng hướng thẳng vào ảnh đã mã hoá nên phải biết trước.
35
+ */
36
+ readonly landscape?: boolean;
37
+ }
38
+
39
+ export interface RtmpConnectOptions {
40
+ /** URL RTMP đầy đủ, đã gồm stream key (rtmp://host:1935/live/sk_...). */
41
+ readonly url: string;
42
+ readonly videoBitrate: number;
43
+ readonly audioBitrate?: number;
44
+ /** false = chuẩn bị sẵn nhưng chưa phát (standby). */
45
+ readonly publish: boolean;
46
+ }
47
+
48
+ export type PickleballRtmpNativeEvents = {
49
+ onStatusChanged(event: NativeRtmpStatus): void;
50
+ onBandwidth(event: NativeRtmpBandwidth): void;
51
+ };
52
+
53
+ /** Một ống kính vật lý của máy (siêu rộng / chính / tele). */
54
+ export interface NativeCameraInfo {
55
+ readonly deviceId: string;
56
+ readonly label: string;
57
+ /** "environment" = camera sau — khớp cách đặt tên của WebRTC. */
58
+ readonly facing: "front" | "environment";
59
+ }
@@ -0,0 +1,29 @@
1
+ import { NativeModule, requireNativeModule } from "expo";
2
+ import type {
3
+ NativeCameraInfo,
4
+ NativeRtmpStats,
5
+ PickleballRtmpNativeEvents,
6
+ RtmpConnectOptions,
7
+ RtmpPrepareOptions,
8
+ } from "./PickleballRtmpNative.types";
9
+
10
+ declare class PickleballRtmpNativeModule extends NativeModule<PickleballRtmpNativeEvents> {
11
+ /** Mở camera + mic và bắt đầu capture (chưa gửi đi đâu). */
12
+ prepare(options: RtmpPrepareOptions): Promise<void>;
13
+ /** Kết nối tới server RTMP; publish ngay nếu options.publish. */
14
+ connect(options: RtmpConnectOptions): Promise<void>;
15
+ /** Bật/tắt phát khi đã kết nối (standby ↔ live). */
16
+ setPublishing(enabled: boolean): Promise<void>;
17
+ disconnect(): Promise<void>;
18
+ switchCamera(): Promise<void>;
19
+ /** Ống kính vật lý: siêu rộng (0.5x), chính (1x), tele. */
20
+ listCameras(): NativeCameraInfo[];
21
+ selectCamera(deviceId: string, facing: string): Promise<void>;
22
+ setMicrophoneEnabled(enabled: boolean): Promise<void>;
23
+ /** Hạ/nâng bitrate khi mạng đổi — thay cho BWE của WebRTC. */
24
+ setVideoBitrate(bitsPerSecond: number): Promise<void>;
25
+ getStats(): NativeRtmpStats;
26
+ dispose(): Promise<void>;
27
+ }
28
+
29
+ export default requireNativeModule<PickleballRtmpNativeModule>("PickleballRtmpNative");
@@ -0,0 +1,12 @@
1
+ import { requireNativeView } from "expo";
2
+ import type { ComponentType } from "react";
3
+ import type { ViewProps } from "react-native";
4
+
5
+ /**
6
+ * Hiển thị camera cho mode device-rtmp.
7
+ *
8
+ * Bắt buộc phải render view này ở đâu đó, nếu không màn preview TỐI ĐEN: native
9
+ * vẫn mở camera và encode bình thường nhưng không có đích để vẽ khung hình.
10
+ */
11
+ export const PickleballRtmpPreviewView: ComponentType<ViewProps> =
12
+ requireNativeView("PickleballRtmpNative");
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default } from "./PickleballRtmpNativeModule";
2
+ export { PickleballRtmpPreviewView } from "./PickleballRtmpPreviewView";
3
+ export type * from "./PickleballRtmpNative.types";