blix-expo-settings 0.1.11 → 0.1.12
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/ios/ExpoSettingsModule.swift +540 -180
- package/ios/ExpoSettingsView.swift +7 -2
- package/package.json +5 -1
|
@@ -2,12 +2,57 @@ import ExpoModulesCore
|
|
|
2
2
|
import HaishinKit
|
|
3
3
|
import AVFoundation
|
|
4
4
|
import VideoToolbox
|
|
5
|
+
import Logboard
|
|
6
|
+
|
|
7
|
+
// MARK: - RTMP Event Observer
|
|
8
|
+
|
|
9
|
+
final class RTMPEventObserver: NSObject {
|
|
10
|
+
var onStatus: ((String, String, String) -> Void)?
|
|
11
|
+
var onError: ((String) -> Void)?
|
|
12
|
+
|
|
13
|
+
@objc func rtmpStatusHandler(_ notification: Notification) {
|
|
14
|
+
let e: Event = Event.from(notification)
|
|
15
|
+
guard let data = e.data as? [String: Any] else { return }
|
|
16
|
+
let code = data["code"] as? String ?? ""
|
|
17
|
+
let level = data["level"] as? String ?? ""
|
|
18
|
+
let desc = data["description"] as? String ?? ""
|
|
19
|
+
onStatus?(code, level, desc)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@objc func rtmpErrorHandler(_ notification: Notification) {
|
|
23
|
+
let e: Event = Event.from(notification)
|
|
24
|
+
onError?("ioError: \(e)")
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// MARK: - Module
|
|
5
29
|
|
|
6
30
|
public class ExpoSettingsModule: Module {
|
|
7
31
|
private var rtmpConnection: RTMPConnection?
|
|
8
32
|
private var rtmpStream: RTMPStream?
|
|
9
33
|
private var currentStreamStatus: String = "stopped"
|
|
34
|
+
private let rtmpObserver = RTMPEventObserver()
|
|
35
|
+
|
|
36
|
+
private var pendingPublish: (url: String, streamKey: String)?
|
|
37
|
+
private var statsTimer: Timer?
|
|
38
|
+
|
|
39
|
+
// Timing/debug
|
|
40
|
+
private var previewInitTime: Date?
|
|
41
|
+
private var publishRequestTime: Date?
|
|
42
|
+
private var firstDataSentTime: Date?
|
|
43
|
+
private var stopRequestTime: Date?
|
|
44
|
+
private var lastDataSentTime: Date?
|
|
45
|
+
|
|
46
|
+
// Stream config
|
|
47
|
+
private let TARGET_ASPECT_RATIO: CGFloat = 9.0 / 16.0
|
|
48
|
+
private var calculatedVideoWidth: Int = 720
|
|
49
|
+
private var calculatedVideoHeight: Int = 1280
|
|
50
|
+
private var configuredBitrate: Int = 2_500_000
|
|
51
|
+
private var configuredFrameRate: Float64 = 30
|
|
10
52
|
|
|
53
|
+
// Monitor cancellation
|
|
54
|
+
private var dataMonitorToken: UUID?
|
|
55
|
+
private var stopFlushToken: UUID?
|
|
11
56
|
|
|
12
57
|
public func definition() -> ModuleDefinition {
|
|
13
58
|
Name("ExpoSettings")
|
|
@@ -18,226 +63,541 @@ public class ExpoSettingsModule: Module {
|
|
|
18
63
|
// não precisa colocar nada aqui se você não tiver Props
|
|
19
64
|
}
|
|
20
65
|
|
|
21
|
-
Events("onStreamStatus")
|
|
66
|
+
Events("onStreamStatus", "onStreamStats", "onStreamTiming")
|
|
22
67
|
|
|
23
68
|
Function("getStreamStatus") {
|
|
24
69
|
return self.currentStreamStatus
|
|
25
70
|
}
|
|
26
71
|
|
|
72
|
+
Function("getStreamInfo") { () -> [String: Any] in
|
|
73
|
+
let w = self.calculatedVideoWidth
|
|
74
|
+
let h = self.calculatedVideoHeight
|
|
75
|
+
let ar = (h == 0) ? 0.0 : (Double(w) / Double(h))
|
|
76
|
+
return [
|
|
77
|
+
"videoWidth": w,
|
|
78
|
+
"videoHeight": h,
|
|
79
|
+
"aspectRatio": String(format: "%.4f", ar),
|
|
80
|
+
"bitrate": self.configuredBitrate,
|
|
81
|
+
"frameRate": self.configuredFrameRate
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Function("getDeviceDimensions") { () -> [String: Any] in
|
|
86
|
+
let screen = UIScreen.main.bounds
|
|
87
|
+
let scale = UIScreen.main.scale
|
|
88
|
+
return [
|
|
89
|
+
"screenWidth": Int(screen.width),
|
|
90
|
+
"screenHeight": Int(screen.height),
|
|
91
|
+
"scale": scale,
|
|
92
|
+
"pixelWidth": Int(screen.width * scale),
|
|
93
|
+
"pixelHeight": Int(screen.height * scale),
|
|
94
|
+
"streamWidth": self.calculatedVideoWidth,
|
|
95
|
+
"streamHeight": self.calculatedVideoHeight,
|
|
96
|
+
"aspectRatio": String(format: "%.4f", Double(self.calculatedVideoWidth) / Double(max(self.calculatedVideoHeight, 1)))
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Function("getStreamTiming") { () -> [String: Any] in
|
|
101
|
+
var result: [String: Any] = [:]
|
|
102
|
+
let fmt = ISO8601DateFormatter()
|
|
103
|
+
|
|
104
|
+
if let t = self.previewInitTime { result["previewInitTime"] = fmt.string(from: t) }
|
|
105
|
+
if let t = self.publishRequestTime { result["publishRequestTime"] = fmt.string(from: t) }
|
|
106
|
+
if let t = self.firstDataSentTime { result["firstDataSentTime"] = fmt.string(from: t) }
|
|
107
|
+
if let t = self.stopRequestTime { result["stopRequestTime"] = fmt.string(from: t) }
|
|
108
|
+
if let t = self.lastDataSentTime { result["lastDataSentTime"] = fmt.string(from: t) }
|
|
109
|
+
|
|
110
|
+
if let publish = self.publishRequestTime, let first = self.firstDataSentTime {
|
|
111
|
+
result["startDelayMs"] = Int(first.timeIntervalSince(publish) * 1000)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if let stop = self.stopRequestTime, let last = self.lastDataSentTime {
|
|
115
|
+
// Positive means stop happened after last data timestamp
|
|
116
|
+
result["timeSinceLastDataMs"] = Int(stop.timeIntervalSince(last) * 1000)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result
|
|
120
|
+
}
|
|
121
|
+
|
|
27
122
|
Function("initializePreview") { () -> Void in
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
|
|
31
|
-
|
|
32
|
-
do {
|
|
33
|
-
|
|
34
|
-
// 0) Configura e ativa o AVAudioSession
|
|
35
|
-
let session = AVAudioSession.sharedInstance()
|
|
36
|
-
do {
|
|
37
|
-
try session.setCategory(.playAndRecord,
|
|
38
|
-
mode: .default,
|
|
39
|
-
options: [.defaultToSpeaker, .allowBluetooth])
|
|
40
|
-
try session.setActive(true)
|
|
41
|
-
} catch {
|
|
42
|
-
print("[ExpoSettings] AVAudioSession error:", error)
|
|
43
|
-
}
|
|
123
|
+
DispatchQueue.main.async { self.initializePreview() }
|
|
124
|
+
}
|
|
44
125
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// 2) Criar RTMPStream, mas não publica pro servidor ainda
|
|
50
|
-
let stream = RTMPStream(connection: connection)
|
|
51
|
-
self.rtmpStream = stream
|
|
52
|
-
print("[ExpoSettings] RTMPStream initialized")
|
|
53
|
-
|
|
54
|
-
// 3) Configurar captura: frame rate e preset
|
|
55
|
-
stream.sessionPreset = .hd1280x720
|
|
56
|
-
stream.frameRate = 30
|
|
57
|
-
stream.videoOrientation = .portrait
|
|
58
|
-
stream.configuration { captureSession in
|
|
59
|
-
captureSession.automaticallyConfiguresApplicationAudioSession = true
|
|
60
|
-
}
|
|
126
|
+
Function("publishStream") { (url: String, streamKey: String) -> Void in
|
|
127
|
+
DispatchQueue.main.async { self.publishStream(url: url, streamKey: streamKey) }
|
|
128
|
+
}
|
|
61
129
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} else {
|
|
67
|
-
print("[ExpoSettings] No audio device found")
|
|
68
|
-
}
|
|
130
|
+
Function("stopStream") { () -> Void in
|
|
131
|
+
DispatchQueue.main.async { self.stopStream() }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
69
134
|
|
|
70
|
-
|
|
71
|
-
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera,
|
|
72
|
-
for: .video,
|
|
73
|
-
position: .front) {
|
|
74
|
-
print("[ExpoSettings] Attaching camera device")
|
|
75
|
-
stream.attachCamera(camera) { videoUnit, error in
|
|
76
|
-
guard let unit = videoUnit else {
|
|
77
|
-
print("[ExpoSettings] attachCamera error:", error?.localizedDescription ?? "unknown")
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
unit.isVideoMirrored = true
|
|
81
|
-
unit.videoOrientation = .portrait
|
|
82
|
-
unit.preferredVideoStabilizationMode = .standard
|
|
83
|
-
unit.colorFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
|
|
135
|
+
// MARK: - Helpers
|
|
84
136
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
print("[ExpoSettings] No camera device found")
|
|
94
|
-
}
|
|
137
|
+
private func setStatus(_ s: String) {
|
|
138
|
+
guard currentStreamStatus != s else { return }
|
|
139
|
+
currentStreamStatus = s
|
|
140
|
+
sendEvent("onStreamStatus", [
|
|
141
|
+
"status": s,
|
|
142
|
+
"timestamp": ISO8601DateFormatter().string(from: Date())
|
|
143
|
+
])
|
|
144
|
+
}
|
|
95
145
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
146
|
+
private func sanitizeRTMPUrl(_ url: String) -> String {
|
|
147
|
+
var u = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
148
|
+
while u.hasSuffix("/") { u.removeLast() }
|
|
149
|
+
return u
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func calculateStreamDimensions() -> (width: Int, height: Int) {
|
|
153
|
+
let width = 720
|
|
154
|
+
let height = 1280
|
|
155
|
+
|
|
156
|
+
let aspectRatio = CGFloat(width) / CGFloat(height)
|
|
157
|
+
let expected = TARGET_ASPECT_RATIO
|
|
158
|
+
|
|
159
|
+
assert(abs(aspectRatio - expected) < 0.001, "Aspect ratio mismatch!")
|
|
160
|
+
|
|
161
|
+
print("[ExpoSettings] 📐 Stream dimensions: \(width)x\(height)")
|
|
162
|
+
print("[ExpoSettings] 📐 Aspect ratio: \(String(format: "%.4f", aspectRatio)) expected \(String(format: "%.4f", expected))")
|
|
163
|
+
return (width, height)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// MARK: - Permissions
|
|
167
|
+
|
|
168
|
+
private func requestAVPermissions(completion: @escaping (Bool) -> Void) {
|
|
169
|
+
let group = DispatchGroup()
|
|
170
|
+
var camOK = false
|
|
171
|
+
var micOK = false
|
|
172
|
+
|
|
173
|
+
group.enter()
|
|
174
|
+
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
175
|
+
camOK = granted
|
|
176
|
+
group.leave()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
group.enter()
|
|
180
|
+
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
181
|
+
micOK = granted
|
|
182
|
+
group.leave()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
group.notify(queue: .main) {
|
|
186
|
+
print("[ExpoSettings] camera permission \(camOK)")
|
|
187
|
+
print("[ExpoSettings] mic permission \(micOK)")
|
|
188
|
+
completion(camOK && micOK)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// MARK: - Preview init
|
|
193
|
+
|
|
194
|
+
private func initializePreview() {
|
|
195
|
+
previewInitTime = Date()
|
|
196
|
+
LBLogger.with("com.haishinkit.HaishinKit").level = .trace
|
|
197
|
+
|
|
198
|
+
print("[ExpoSettings] ⏱️ initializePreview at \(ISO8601DateFormatter().string(from: previewInitTime!))")
|
|
199
|
+
setStatus("previewInitializing")
|
|
200
|
+
|
|
201
|
+
requestAVPermissions { [weak self] ok in
|
|
202
|
+
guard let self else { return }
|
|
203
|
+
guard ok else {
|
|
204
|
+
print("[ExpoSettings] ❌ Missing camera/mic permissions")
|
|
205
|
+
self.setStatus("error")
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Audio session
|
|
210
|
+
let session = AVAudioSession.sharedInstance()
|
|
211
|
+
do {
|
|
212
|
+
try session.setCategory(.playAndRecord, mode: .videoRecording, options: [.defaultToSpeaker, .allowBluetooth])
|
|
213
|
+
try session.setPreferredSampleRate(44_100)
|
|
214
|
+
try session.setActive(true)
|
|
215
|
+
print("[ExpoSettings] ✅ AudioSession OK")
|
|
216
|
+
} catch {
|
|
217
|
+
print("[ExpoSettings] ❌ AudioSession error: \(error)")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let connection = RTMPConnection()
|
|
221
|
+
let stream = RTMPStream(connection: connection)
|
|
222
|
+
|
|
223
|
+
// Attach listeners
|
|
224
|
+
connection.addEventListener(.rtmpStatus,
|
|
225
|
+
selector: #selector(RTMPEventObserver.rtmpStatusHandler(_:)),
|
|
226
|
+
observer: self.rtmpObserver)
|
|
227
|
+
connection.addEventListener(.ioError,
|
|
228
|
+
selector: #selector(RTMPEventObserver.rtmpErrorHandler(_:)),
|
|
229
|
+
observer: self.rtmpObserver)
|
|
230
|
+
|
|
231
|
+
stream.addEventListener(.rtmpStatus,
|
|
232
|
+
selector: #selector(RTMPEventObserver.rtmpStatusHandler(_:)),
|
|
233
|
+
observer: self.rtmpObserver)
|
|
234
|
+
stream.addEventListener(.ioError,
|
|
235
|
+
selector: #selector(RTMPEventObserver.rtmpErrorHandler(_:)),
|
|
236
|
+
observer: self.rtmpObserver)
|
|
237
|
+
|
|
238
|
+
self.rtmpConnection = connection
|
|
239
|
+
self.rtmpStream = stream
|
|
240
|
+
|
|
241
|
+
self.rtmpObserver.onStatus = { [weak self] code, level, desc in
|
|
242
|
+
self?.handleRTMPStatus(code: code, level: level, desc: desc)
|
|
243
|
+
}
|
|
244
|
+
self.rtmpObserver.onError = { [weak self] msg in
|
|
245
|
+
print("[ExpoSettings] ❌ \(msg)")
|
|
246
|
+
self?.setStatus("error")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Dimensions
|
|
250
|
+
let dimensions = self.calculateStreamDimensions()
|
|
251
|
+
self.calculatedVideoWidth = dimensions.width
|
|
252
|
+
self.calculatedVideoHeight = dimensions.height
|
|
253
|
+
|
|
254
|
+
// Video settings
|
|
255
|
+
self.configuredBitrate = 2_500_000
|
|
256
|
+
self.configuredFrameRate = 30
|
|
101
257
|
|
|
102
258
|
let videoSettings = VideoCodecSettings(
|
|
103
|
-
videoSize:
|
|
104
|
-
bitRate:
|
|
259
|
+
videoSize: CGSize(width: dimensions.width, height: dimensions.height),
|
|
260
|
+
bitRate: self.configuredBitrate,
|
|
105
261
|
profileLevel: kVTProfileLevel_H264_Baseline_3_1 as String,
|
|
106
262
|
scalingMode: .trim,
|
|
107
263
|
bitRateMode: .average,
|
|
108
|
-
|
|
264
|
+
maxKeyFrameIntervalDuration: 1, // GOP 1s
|
|
109
265
|
allowFrameReordering: nil,
|
|
110
266
|
isHardwareEncoderEnabled: true
|
|
111
|
-
|
|
112
|
-
|
|
267
|
+
)
|
|
268
|
+
stream.videoSettings = videoSettings
|
|
269
|
+
stream.frameRate = self.configuredFrameRate
|
|
270
|
+
|
|
271
|
+
print("[ExpoSettings] 📐 VideoSettings videoSize=\(stream.videoSettings.videoSize) bitrate=\(stream.videoSettings.bitRate) GOP=\(stream.videoSettings.maxKeyFrameIntervalDuration) fps=\(stream.frameRate)")
|
|
272
|
+
|
|
273
|
+
// Audio settings
|
|
274
|
+
var audioSettings = AudioCodecSettings()
|
|
275
|
+
audioSettings.bitRate = 128_000
|
|
276
|
+
stream.audioSettings = audioSettings
|
|
277
|
+
print("[ExpoSettings] 🔊 Audio bitRate: 128000")
|
|
278
|
+
|
|
279
|
+
// Devices
|
|
280
|
+
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
|
|
281
|
+
print("[ExpoSettings] ❌ No front camera")
|
|
282
|
+
self.setStatus("error")
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
guard let microphone = AVCaptureDevice.default(for: .audio) else {
|
|
286
|
+
print("[ExpoSettings] ❌ No microphone")
|
|
287
|
+
self.setStatus("error")
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Attach camera (portrait, mirrored)
|
|
292
|
+
stream.attachCamera(camera) { videoUnit, error in
|
|
293
|
+
if let error = error {
|
|
294
|
+
print("[ExpoSettings] ❌ Camera ERROR: \(error)")
|
|
295
|
+
} else {
|
|
296
|
+
videoUnit?.isVideoMirrored = true
|
|
297
|
+
videoUnit?.videoOrientation = .portrait
|
|
298
|
+
print("[ExpoSettings] ✅ Camera attached (portrait, mirrored)")
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
stream.attachAudio(microphone) { _, error in
|
|
303
|
+
if let error = error {
|
|
304
|
+
print("[ExpoSettings] ❌ Audio ERROR: \(error.localizedDescription)")
|
|
305
|
+
} else {
|
|
306
|
+
print("[ExpoSettings] ✅ Audio attached")
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Attach preview
|
|
311
|
+
if let preview = ExpoSettingsView.current {
|
|
312
|
+
preview.attachStream(stream) // requires RTMPStream? in view to allow nil later
|
|
313
|
+
print("[ExpoSettings] ✅ Preview attached")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Wait for encoder warm-up
|
|
317
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
|
318
|
+
guard let self, let s = self.rtmpStream else { return }
|
|
319
|
+
print("[ExpoSettings] 🔍 Warm verify videoSize=\(s.videoSettings.videoSize) fps=\(s.frameRate)")
|
|
320
|
+
self.setStatus("previewReady")
|
|
321
|
+
print("[ExpoSettings] ✅ Preview READY")
|
|
113
322
|
}
|
|
114
|
-
|
|
115
|
-
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// MARK: - Publish
|
|
327
|
+
|
|
328
|
+
private func publishStream(url: String, streamKey: String) {
|
|
329
|
+
publishRequestTime = Date()
|
|
330
|
+
firstDataSentTime = nil
|
|
331
|
+
lastDataSentTime = nil
|
|
332
|
+
stopRequestTime = nil
|
|
333
|
+
|
|
334
|
+
// reset monitors
|
|
335
|
+
dataMonitorToken = UUID()
|
|
336
|
+
|
|
337
|
+
let cleanUrl = sanitizeRTMPUrl(url)
|
|
338
|
+
print("[ExpoSettings] ⏱️ publishStream at \(ISO8601DateFormatter().string(from: publishRequestTime!))")
|
|
339
|
+
print("[ExpoSettings] URL: \(cleanUrl)")
|
|
340
|
+
print("[ExpoSettings] Key: \(streamKey)")
|
|
341
|
+
|
|
342
|
+
guard let connection = rtmpConnection, let stream = rtmpStream else {
|
|
343
|
+
print("[ExpoSettings] ❌ No connection/stream")
|
|
344
|
+
setStatus("error")
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
print("[ExpoSettings] 🔍 Pre-publish videoSize=\(stream.videoSettings.videoSize)")
|
|
349
|
+
|
|
350
|
+
pendingPublish = (cleanUrl, streamKey)
|
|
351
|
+
setStatus("connecting")
|
|
352
|
+
connection.connect(cleanUrl)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// MARK: - RTMP status
|
|
356
|
+
|
|
357
|
+
private func handleRTMPStatus(code: String, level: String, desc: String) {
|
|
358
|
+
let now = Date()
|
|
359
|
+
print("[ExpoSettings] ⏱️ RTMP status \(code) at \(ISO8601DateFormatter().string(from: now))")
|
|
360
|
+
|
|
361
|
+
guard let stream = rtmpStream else { return }
|
|
362
|
+
|
|
363
|
+
switch code {
|
|
364
|
+
case "NetConnection.Connect.Success":
|
|
365
|
+
setStatus("connected")
|
|
366
|
+
if let p = pendingPublish {
|
|
367
|
+
pendingPublish = nil
|
|
368
|
+
setStatus("publishing")
|
|
369
|
+
print("[ExpoSettings] 📤 Publishing...")
|
|
370
|
+
stream.publish(p.streamKey, type: .live)
|
|
371
|
+
|
|
372
|
+
// Start monitoring for real media egress
|
|
373
|
+
monitorForRealOutboundMedia()
|
|
116
374
|
}
|
|
375
|
+
|
|
376
|
+
case "NetStream.Publish.Start":
|
|
377
|
+
// IMPORTANT:
|
|
378
|
+
// Do NOT setStatus("started") here anymore.
|
|
379
|
+
// This event means publish handshake started, not necessarily that DVR/RTMP has real media yet.
|
|
380
|
+
print("[ExpoSettings] ✅ Publish.Start received (waiting for data confirmation...)")
|
|
381
|
+
|
|
382
|
+
case "NetStream.Publish.BadName",
|
|
383
|
+
"NetStream.Publish.Rejected",
|
|
384
|
+
"NetConnection.Connect.Failed":
|
|
385
|
+
stopStatsTimer()
|
|
386
|
+
setStatus("error")
|
|
387
|
+
|
|
388
|
+
case "NetConnection.Connect.Closed":
|
|
389
|
+
stopStatsTimer()
|
|
390
|
+
setStatus("stopped")
|
|
391
|
+
|
|
392
|
+
default:
|
|
393
|
+
break
|
|
117
394
|
}
|
|
395
|
+
}
|
|
118
396
|
|
|
119
|
-
|
|
120
|
-
Task {
|
|
121
|
-
|
|
122
|
-
print("[ExpoSettings] Publishing stream to URL: \(url) with key: \(streamKey)")
|
|
123
|
-
|
|
124
|
-
self.currentStreamStatus = "connecting"
|
|
125
|
-
sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
|
|
126
|
-
|
|
127
|
-
// se não houve initializePreview→recria a connection
|
|
128
|
-
if self.rtmpConnection == nil || self.rtmpStream == nil {
|
|
129
|
-
print("[ExpoSettings] WARNING: Connection or stream not initialized, creating new ones")
|
|
130
|
-
// Create new connection
|
|
131
|
-
let connection = RTMPConnection()
|
|
132
|
-
self.rtmpConnection = connection
|
|
133
|
-
|
|
134
|
-
// Create new stream
|
|
135
|
-
let stream = RTMPStream(connection: connection)
|
|
136
|
-
self.rtmpStream = stream
|
|
137
|
-
|
|
138
|
-
// Captura: preset antes do FPS + orientação no stream
|
|
139
|
-
stream.sessionPreset = .hd1280x720
|
|
140
|
-
stream.frameRate = 30
|
|
141
|
-
stream.videoOrientation = .portrait
|
|
142
|
-
stream.configuration { captureSession in
|
|
143
|
-
captureSession.automaticallyConfiguresApplicationAudioSession = true
|
|
144
|
-
}
|
|
397
|
+
// MARK: - Start confirmation (Fix #1)
|
|
145
398
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
399
|
+
private func monitorForRealOutboundMedia() {
|
|
400
|
+
guard let connection = rtmpConnection, let stream = rtmpStream else { return }
|
|
401
|
+
let token = dataMonitorToken ?? UUID()
|
|
402
|
+
dataMonitorToken = token
|
|
150
403
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
stream.attachCamera(camera) { videoUnit, error in
|
|
155
|
-
guard let unit = videoUnit else {
|
|
156
|
-
print("[ExpoSettings] attachCamera error:", error?.localizedDescription ?? "unknown")
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
unit.isVideoMirrored = true
|
|
160
|
-
unit.videoOrientation = .portrait
|
|
161
|
-
unit.preferredVideoStabilizationMode = .standard
|
|
162
|
-
unit.colorFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
|
|
163
|
-
}
|
|
164
|
-
}
|
|
404
|
+
var checks = 0
|
|
405
|
+
let maxChecks = 200 // 20s (200 x 100ms)
|
|
406
|
+
let interval: TimeInterval = 0.1
|
|
165
407
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
} else {
|
|
170
|
-
print("[ExpoSettings] ERROR: Preview view not found during publish!")
|
|
171
|
-
}
|
|
408
|
+
// Require a few consecutive "good" checks to avoid flapping
|
|
409
|
+
var goodStreak = 0
|
|
410
|
+
let neededGoodStreak = 4 // 400ms stable
|
|
172
411
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
self.
|
|
412
|
+
func tick() {
|
|
413
|
+
// cancelled?
|
|
414
|
+
guard self.dataMonitorToken == token else { return }
|
|
415
|
+
|
|
416
|
+
checks += 1
|
|
417
|
+
|
|
418
|
+
let bytesOut = connection.currentBytesOutPerSecond // Int32
|
|
419
|
+
let fps = stream.currentFPS
|
|
420
|
+
|
|
421
|
+
// Track last data time if any egress
|
|
422
|
+
if bytesOut > 0 && fps > 0 {
|
|
423
|
+
self.lastDataSentTime = Date()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if bytesOut > 0 && fps > 0 {
|
|
427
|
+
goodStreak += 1
|
|
428
|
+
} else {
|
|
429
|
+
goodStreak = 0
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Confirm start ONLY when stable outbound is observed
|
|
433
|
+
if goodStreak >= neededGoodStreak {
|
|
434
|
+
if self.firstDataSentTime == nil {
|
|
435
|
+
self.firstDataSentTime = Date()
|
|
197
436
|
}
|
|
198
|
-
self.currentStreamStatus = "connected"
|
|
199
|
-
sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
|
|
200
437
|
|
|
201
|
-
self.
|
|
202
|
-
|
|
438
|
+
let delayMs = self.publishRequestTime.flatMap { pub in
|
|
439
|
+
self.firstDataSentTime.map { Int($0.timeIntervalSince(pub) * 1000) }
|
|
440
|
+
} ?? -1
|
|
441
|
+
|
|
442
|
+
print("[ExpoSettings] ✅ Data confirmed (bytesOut=\(bytesOut), fps=\(fps)) after \(delayMs)ms")
|
|
443
|
+
|
|
444
|
+
// Emit timing events (send both names to match any JS)
|
|
445
|
+
self.sendEvent("onStreamTiming", [
|
|
446
|
+
"event": "dataConfirmed",
|
|
447
|
+
"delayMs": delayMs,
|
|
448
|
+
"timestamp": ISO8601DateFormatter().string(from: self.firstDataSentTime ?? Date())
|
|
449
|
+
])
|
|
450
|
+
self.sendEvent("onStreamTiming", [
|
|
451
|
+
"event": "firstDataSent",
|
|
452
|
+
"delayMs": delayMs,
|
|
453
|
+
"timestamp": ISO8601DateFormatter().string(from: self.firstDataSentTime ?? Date())
|
|
454
|
+
])
|
|
455
|
+
|
|
456
|
+
self.setStatus("started")
|
|
457
|
+
self.startStatsTimer()
|
|
458
|
+
return
|
|
459
|
+
}
|
|
203
460
|
|
|
204
|
-
|
|
205
|
-
|
|
461
|
+
// Timeout
|
|
462
|
+
if checks >= maxChecks {
|
|
463
|
+
print("[ExpoSettings] ⚠️ Start confirmation timeout (still no stable outbound media). Keeping status=\(self.currentStreamStatus)")
|
|
464
|
+
// Keep status as "publishing" or whatever it currently is; do not force started.
|
|
465
|
+
return
|
|
466
|
+
}
|
|
206
467
|
|
|
207
|
-
|
|
208
|
-
|
|
468
|
+
// Keep checking while in publishing/connected state
|
|
469
|
+
if self.currentStreamStatus == "publishing" || self.currentStreamStatus == "connected" || self.currentStreamStatus == "connecting" {
|
|
470
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + interval) { tick() }
|
|
209
471
|
}
|
|
210
472
|
}
|
|
211
473
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
print("[ExpoSettings] stopStream called")
|
|
474
|
+
tick()
|
|
475
|
+
}
|
|
215
476
|
|
|
216
|
-
|
|
217
|
-
if let stream = self.rtmpStream {
|
|
218
|
-
print("[ExpoSettings] Stopping stream publication")
|
|
219
|
-
stream.close()
|
|
477
|
+
// MARK: - Stats
|
|
220
478
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
479
|
+
private func startStatsTimer() {
|
|
480
|
+
stopStatsTimer()
|
|
481
|
+
statsTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
482
|
+
guard let self,
|
|
483
|
+
let c = self.rtmpConnection,
|
|
484
|
+
let s = self.rtmpStream else { return }
|
|
225
485
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
486
|
+
let fps = s.currentFPS
|
|
487
|
+
let bps = c.currentBytesOutPerSecond * 8
|
|
488
|
+
|
|
489
|
+
if bps > 0 {
|
|
490
|
+
self.lastDataSentTime = Date()
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
self.sendEvent("onStreamStats", [
|
|
494
|
+
"fps": fps,
|
|
495
|
+
"bps": bps,
|
|
496
|
+
"timestamp": ISO8601DateFormatter().string(from: Date())
|
|
497
|
+
])
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private func stopStatsTimer() {
|
|
502
|
+
statsTimer?.invalidate()
|
|
503
|
+
statsTimer = nil
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// MARK: - Stop (Fix #2)
|
|
507
|
+
|
|
508
|
+
private func stopStream() {
|
|
509
|
+
stopRequestTime = Date()
|
|
510
|
+
print("[ExpoSettings] ⏱️ stopStream at \(ISO8601DateFormatter().string(from: stopRequestTime!))")
|
|
511
|
+
|
|
512
|
+
// cancel start confirmation monitor
|
|
513
|
+
dataMonitorToken = UUID()
|
|
514
|
+
|
|
515
|
+
stopStatsTimer()
|
|
516
|
+
|
|
517
|
+
guard let stream = rtmpStream, let connection = rtmpConnection else {
|
|
518
|
+
print("[ExpoSettings] No active stream to stop")
|
|
519
|
+
setStatus("stopped")
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
setStatus("stopping")
|
|
524
|
+
|
|
525
|
+
// Stop capturing new frames but keep connection open for flush
|
|
526
|
+
print("[ExpoSettings] 📤 Stop capture (keep RTMP open for flush)")
|
|
527
|
+
stream.attachCamera(nil) { _, _ in }
|
|
528
|
+
stream.attachAudio(nil) { _, _ in }
|
|
529
|
+
|
|
530
|
+
// Adaptive flush: wait until outbound bytes are ~0 for a stable window, OR max time reached
|
|
531
|
+
stopFlushToken = UUID()
|
|
532
|
+
let token = stopFlushToken!
|
|
231
533
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
534
|
+
let interval: TimeInterval = 0.2
|
|
535
|
+
let maxFlushSeconds: TimeInterval = 12.0
|
|
536
|
+
let stableZeroNeeded: Int = 6 // 6 * 0.2s = 1.2s stable
|
|
235
537
|
|
|
236
|
-
|
|
538
|
+
var elapsed: TimeInterval = 0
|
|
539
|
+
var stableZeroCount = 0
|
|
237
540
|
|
|
238
|
-
|
|
239
|
-
|
|
541
|
+
func flushTick() {
|
|
542
|
+
guard self.stopFlushToken == token else { return }
|
|
543
|
+
|
|
544
|
+
let bytesOut = connection.currentBytesOutPerSecond
|
|
545
|
+
let now = Date()
|
|
546
|
+
|
|
547
|
+
// if still sending, update lastDataSentTime
|
|
548
|
+
if bytesOut > 0 {
|
|
549
|
+
self.lastDataSentTime = now
|
|
550
|
+
stableZeroCount = 0
|
|
551
|
+
} else {
|
|
552
|
+
stableZeroCount += 1
|
|
240
553
|
}
|
|
554
|
+
|
|
555
|
+
elapsed += interval
|
|
556
|
+
|
|
557
|
+
// Condition to proceed: stable no outbound OR max wait
|
|
558
|
+
if stableZeroCount >= stableZeroNeeded || elapsed >= maxFlushSeconds {
|
|
559
|
+
print("[ExpoSettings] ✅ Flush condition met (stableZeroCount=\(stableZeroCount), elapsed=\(String(format: "%.1f", elapsed))s). Closing stream...")
|
|
560
|
+
|
|
561
|
+
// Close stream then connection
|
|
562
|
+
self.rtmpStream?.close()
|
|
563
|
+
|
|
564
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
565
|
+
guard let self else { return }
|
|
566
|
+
self.rtmpConnection?.close()
|
|
567
|
+
|
|
568
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
569
|
+
guard let self else { return }
|
|
570
|
+
|
|
571
|
+
// Detach preview if your view supports optional
|
|
572
|
+
if let preview = ExpoSettingsView.current {
|
|
573
|
+
preview.attachStream(nil)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let finalTime = Date()
|
|
577
|
+
let totalMs = self.stopRequestTime.map { Int(finalTime.timeIntervalSince($0) * 1000) } ?? -1
|
|
578
|
+
|
|
579
|
+
self.sendEvent("onStreamTiming", [
|
|
580
|
+
"event": "shutdownComplete",
|
|
581
|
+
"totalDurationMs": totalMs,
|
|
582
|
+
"timestamp": ISO8601DateFormatter().string(from: finalTime)
|
|
583
|
+
])
|
|
584
|
+
|
|
585
|
+
self.rtmpStream = nil
|
|
586
|
+
self.rtmpConnection = nil
|
|
587
|
+
self.pendingPublish = nil
|
|
588
|
+
|
|
589
|
+
self.setStatus("stopped")
|
|
590
|
+
print("[ExpoSettings] ✅ Stream stopped (total \(totalMs)ms)")
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Keep flushing
|
|
598
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + interval) { flushTick() }
|
|
241
599
|
}
|
|
600
|
+
|
|
601
|
+
flushTick()
|
|
242
602
|
}
|
|
243
603
|
}
|
|
@@ -11,6 +11,9 @@ public class ExpoSettingsView: ExpoView {
|
|
|
11
11
|
return view
|
|
12
12
|
}()
|
|
13
13
|
|
|
14
|
+
// Guarda stream para reattach se a view recriar/layout mudar
|
|
15
|
+
private weak var attachedStream: RTMPStream?
|
|
16
|
+
|
|
14
17
|
required init(appContext: AppContext? = nil) {
|
|
15
18
|
super.init(appContext: appContext)
|
|
16
19
|
clipsToBounds = true
|
|
@@ -24,8 +27,10 @@ public class ExpoSettingsView: ExpoView {
|
|
|
24
27
|
hkView.frame = bounds
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
// agora aceita nil
|
|
31
|
+
public func attachStream(_ stream: RTMPStream?) {
|
|
32
|
+
attachedStream = stream
|
|
33
|
+
hkView.attachStream(stream) // normalmente aceita nil
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
deinit {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blix-expo-settings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "LiveStream",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"homepage": "https://github.com/BlixTechnology/expo-settings#readme",
|
|
31
31
|
"devDependencies": {
|
|
32
|
+
"@react-native-community/cli-server-api": "^20.1.1",
|
|
32
33
|
"@types/react": "~19.0.0",
|
|
33
34
|
"expo": "^50.0.21",
|
|
34
35
|
"expo-module-scripts": "^4.1.7",
|
|
@@ -38,5 +39,8 @@
|
|
|
38
39
|
"expo": "*",
|
|
39
40
|
"react": "*",
|
|
40
41
|
"react-native": "*"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"expo-dev-client": "~3.3.12"
|
|
41
45
|
}
|
|
42
46
|
}
|