blix-expo-settings 0.1.10 → 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 +537 -119
- package/ios/ExpoSettingsView.swift +7 -2
- package/package.json +5 -1
|
@@ -2,184 +2,602 @@ 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
|
|
52
|
+
|
|
53
|
+
// Monitor cancellation
|
|
54
|
+
private var dataMonitorToken: UUID?
|
|
55
|
+
private var stopFlushToken: UUID?
|
|
10
56
|
|
|
11
57
|
public func definition() -> ModuleDefinition {
|
|
12
58
|
Name("ExpoSettings")
|
|
13
59
|
|
|
14
|
-
|
|
60
|
+
// Registra o view component para o admin se enxergar na live
|
|
61
|
+
|
|
62
|
+
View(ExpoSettingsView.self) {
|
|
63
|
+
// não precisa colocar nada aqui se você não tiver Props
|
|
64
|
+
}
|
|
15
65
|
|
|
16
|
-
Events("onStreamStatus")
|
|
66
|
+
Events("onStreamStatus", "onStreamStats", "onStreamTiming")
|
|
17
67
|
|
|
18
68
|
Function("getStreamStatus") {
|
|
19
69
|
return self.currentStreamStatus
|
|
20
70
|
}
|
|
21
71
|
|
|
22
|
-
Function("
|
|
23
|
-
self.
|
|
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
|
+
}
|
|
24
84
|
|
|
25
|
-
|
|
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
|
+
}
|
|
26
99
|
|
|
27
|
-
|
|
28
|
-
|
|
100
|
+
Function("getStreamTiming") { () -> [String: Any] in
|
|
101
|
+
var result: [String: Any] = [:]
|
|
102
|
+
let fmt = ISO8601DateFormatter()
|
|
29
103
|
|
|
30
|
-
let
|
|
31
|
-
self.
|
|
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) }
|
|
32
109
|
|
|
33
|
-
self.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
self.attachPreviewIfAvailable(stream)
|
|
110
|
+
if let publish = self.publishRequestTime, let first = self.firstDataSentTime {
|
|
111
|
+
result["startDelayMs"] = Int(first.timeIntervalSince(publish) * 1000)
|
|
112
|
+
}
|
|
37
113
|
|
|
38
|
-
self.
|
|
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
|
+
|
|
122
|
+
Function("initializePreview") { () -> Void in
|
|
123
|
+
DispatchQueue.main.async { self.initializePreview() }
|
|
39
124
|
}
|
|
40
125
|
|
|
41
126
|
Function("publishStream") { (url: String, streamKey: String) -> Void in
|
|
42
|
-
self.
|
|
43
|
-
|
|
127
|
+
DispatchQueue.main.async { self.publishStream(url: url, streamKey: streamKey) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Function("stopStream") { () -> Void in
|
|
131
|
+
DispatchQueue.main.async { self.stopStream() }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
44
134
|
|
|
45
|
-
|
|
46
|
-
if self.rtmpConnection == nil || self.rtmpStream == nil {
|
|
47
|
-
let connection = RTMPConnection()
|
|
48
|
-
self.rtmpConnection = connection
|
|
135
|
+
// MARK: - Helpers
|
|
49
136
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
|
52
145
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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!")
|
|
58
160
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
61
167
|
|
|
62
|
-
|
|
63
|
-
|
|
168
|
+
private func requestAVPermissions(completion: @escaping (Bool) -> Void) {
|
|
169
|
+
let group = DispatchGroup()
|
|
170
|
+
var camOK = false
|
|
171
|
+
var micOK = false
|
|
64
172
|
|
|
65
|
-
|
|
173
|
+
group.enter()
|
|
174
|
+
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
175
|
+
camOK = granted
|
|
176
|
+
group.leave()
|
|
66
177
|
}
|
|
67
178
|
|
|
68
|
-
|
|
69
|
-
|
|
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")
|
|
70
200
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
75
207
|
}
|
|
76
208
|
|
|
77
|
-
|
|
78
|
-
|
|
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)")
|
|
79
218
|
}
|
|
80
219
|
|
|
81
|
-
|
|
82
|
-
|
|
220
|
+
let connection = RTMPConnection()
|
|
221
|
+
let stream = RTMPStream(connection: connection)
|
|
83
222
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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)
|
|
87
237
|
|
|
88
|
-
|
|
238
|
+
self.rtmpConnection = connection
|
|
239
|
+
self.rtmpStream = stream
|
|
89
240
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
94
248
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
257
|
+
|
|
258
|
+
let videoSettings = VideoCodecSettings(
|
|
259
|
+
videoSize: CGSize(width: dimensions.width, height: dimensions.height),
|
|
260
|
+
bitRate: self.configuredBitrate,
|
|
261
|
+
profileLevel: kVTProfileLevel_H264_Baseline_3_1 as String,
|
|
262
|
+
scalingMode: .trim,
|
|
263
|
+
bitRateMode: .average,
|
|
264
|
+
maxKeyFrameIntervalDuration: 1, // GOP 1s
|
|
265
|
+
allowFrameReordering: nil,
|
|
266
|
+
isHardwareEncoderEnabled: true
|
|
102
267
|
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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")
|
|
322
|
+
}
|
|
106
323
|
}
|
|
107
324
|
}
|
|
108
325
|
|
|
109
|
-
|
|
110
|
-
print("[ExpoSettings] Configuring stream...")
|
|
111
|
-
|
|
112
|
-
// 1) Captura previsível (16:9)
|
|
113
|
-
stream.sessionPreset = .hd1280x720
|
|
114
|
-
stream.frameRate = 30
|
|
115
|
-
|
|
116
|
-
// 2) Orientação no nível do stream (governa pipeline)
|
|
117
|
-
stream.videoOrientation = .portrait
|
|
118
|
-
|
|
119
|
-
// 3) Áudio
|
|
120
|
-
var audioSettings = AudioCodecSettings()
|
|
121
|
-
audioSettings.bitRate = 128 * 1000
|
|
122
|
-
stream.audioSettings = audioSettings
|
|
123
|
-
|
|
124
|
-
// 4) Vídeo (9:16). Use 720x1280 para estabilidade
|
|
125
|
-
let videoSettings = VideoCodecSettings(
|
|
126
|
-
videoSize: .init(width: 720, height: 1280),
|
|
127
|
-
bitRate: 4_000 * 1000,
|
|
128
|
-
profileLevel: kVTProfileLevel_H264_Baseline_3_1 as String,
|
|
129
|
-
scalingMode: .trim, // sem distorção (corta). Alternativa: .letterbox (barras)
|
|
130
|
-
bitRateMode: .average,
|
|
131
|
-
maxKeyFrameIntervalDuration: 2,
|
|
132
|
-
allowFrameReordering: nil,
|
|
133
|
-
isHardwareEncoderEnabled: true
|
|
134
|
-
)
|
|
135
|
-
stream.videoSettings = videoSettings
|
|
136
|
-
|
|
137
|
-
print("[ExpoSettings] Stream configured preset=\(stream.sessionPreset.rawValue) fps=\(stream.frameRate)")
|
|
138
|
-
print("[ExpoSettings] Target=\(Int(videoSettings.videoSize.width))x\(Int(videoSettings.videoSize.height)) orientation=portrait")
|
|
139
|
-
}
|
|
326
|
+
// MARK: - Publish
|
|
140
327
|
|
|
141
|
-
private func
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
147
346
|
}
|
|
347
|
+
|
|
348
|
+
print("[ExpoSettings] 🔍 Pre-publish videoSize=\(stream.videoSettings.videoSize)")
|
|
349
|
+
|
|
350
|
+
pendingPublish = (cleanUrl, streamKey)
|
|
351
|
+
setStatus("connecting")
|
|
352
|
+
connection.connect(cleanUrl)
|
|
148
353
|
}
|
|
149
354
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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()
|
|
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
|
|
158
394
|
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// MARK: - Start confirmation (Fix #1)
|
|
398
|
+
|
|
399
|
+
private func monitorForRealOutboundMedia() {
|
|
400
|
+
guard let connection = rtmpConnection, let stream = rtmpStream else { return }
|
|
401
|
+
let token = dataMonitorToken ?? UUID()
|
|
402
|
+
dataMonitorToken = token
|
|
159
403
|
|
|
160
|
-
|
|
404
|
+
var checks = 0
|
|
405
|
+
let maxChecks = 200 // 20s (200 x 100ms)
|
|
406
|
+
let interval: TimeInterval = 0.1
|
|
161
407
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
408
|
+
// Require a few consecutive "good" checks to avoid flapping
|
|
409
|
+
var goodStreak = 0
|
|
410
|
+
let neededGoodStreak = 4 // 400ms stable
|
|
411
|
+
|
|
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()
|
|
436
|
+
}
|
|
437
|
+
|
|
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()
|
|
165
458
|
return
|
|
166
459
|
}
|
|
167
460
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|
|
467
|
+
|
|
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() }
|
|
471
|
+
}
|
|
172
472
|
}
|
|
473
|
+
|
|
474
|
+
tick()
|
|
173
475
|
}
|
|
174
476
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
477
|
+
// MARK: - Stats
|
|
478
|
+
|
|
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 }
|
|
485
|
+
|
|
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!
|
|
533
|
+
|
|
534
|
+
let interval: TimeInterval = 0.2
|
|
535
|
+
let maxFlushSeconds: TimeInterval = 12.0
|
|
536
|
+
let stableZeroNeeded: Int = 6 // 6 * 0.2s = 1.2s stable
|
|
537
|
+
|
|
538
|
+
var elapsed: TimeInterval = 0
|
|
539
|
+
var stableZeroCount = 0
|
|
540
|
+
|
|
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
|
|
180
551
|
} else {
|
|
181
|
-
|
|
552
|
+
stableZeroCount += 1
|
|
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
|
|
182
595
|
}
|
|
596
|
+
|
|
597
|
+
// Keep flushing
|
|
598
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + interval) { flushTick() }
|
|
183
599
|
}
|
|
600
|
+
|
|
601
|
+
flushTick()
|
|
184
602
|
}
|
|
185
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
|
}
|