@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 +21 -0
- package/android/build.gradle +30 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpNativeModule.kt +340 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpPreviewView.kt +61 -0
- package/expo-module.config.json +16 -0
- package/ios/PickleballRtmpNative.podspec +17 -0
- package/ios/PickleballRtmpNativeModule.swift +359 -0
- package/ios/PickleballRtmpPreviewView.swift +74 -0
- package/package.json +44 -0
- package/src/PickleballRtmpNative.types.ts +59 -0
- package/src/PickleballRtmpNativeModule.ts +29 -0
- package/src/PickleballRtmpPreviewView.ts +12 -0
- package/src/index.ts +3 -0
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,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