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.
@@ -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
- Task {
29
- self.currentStreamStatus = "previewInitializing"
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
- // 1) Conectar ao servidor RTMP, mas não publica
46
- let connection = RTMPConnection()
47
- self.rtmpConnection = connection
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
- // 4) Configurar áudio: anexa microfone
63
- if let audioDevice = AVCaptureDevice.default(for: .audio) {
64
- print("[ExpoSettings] Attaching audio device")
65
- stream.attachAudio(audioDevice)
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
- // 5) Configurar vídeo: anexa câmera frontal
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
- if let preview = await ExpoSettingsView.current {
87
- print("[ExpoSettings] Attaching stream to preview view")
88
- await preview.attachStream(stream)
89
- } else {
90
- print("[ExpoSettings] ERROR: Preview view not found!")
91
- }
92
- } else {
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
- //6) Definir configurações de codec
97
- print("[ExpoSettings] Setting audio and video codecs")
98
- var audioSettings = AudioCodecSettings()
99
- audioSettings.bitRate = 128 * 1000
100
- stream.audioSettings = audioSettings
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: .init(width: 720, height: 1280),
104
- bitRate: 4000 * 1000,
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
- maxKeyFrameIntervalDuration: 2,
264
+ maxKeyFrameIntervalDuration: 1, // GOP 1s
109
265
  allowFrameReordering: nil,
110
266
  isHardwareEncoderEnabled: true
111
- )
112
- stream.videoSettings = videoSettings
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
- self.currentStreamStatus = "previewReady"
115
- sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
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
- Function("publishStream") { (url: String, streamKey: String) -> Void in
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
- // Áudio
147
- if let audioDevice = AVCaptureDevice.default(for: .audio) {
148
- stream.attachAudio(audioDevice)
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
- if let camera = AVCaptureDevice.default(.builtInWideAngleCamera,
152
- for: .video,
153
- position: .front) {
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
- // Attach preview (se existir)
167
- if let preview = await ExpoSettingsView.current {
168
- await preview.attachStream(stream)
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
- var audioSettings = AudioCodecSettings()
174
- audioSettings.bitRate = 128 * 1000 // 128 kbps
175
- stream.audioSettings = audioSettings
176
-
177
- // Vídeo
178
- let videoSettings = VideoCodecSettings(
179
- videoSize: .init(width: 720, height: 1280),
180
- bitRate: 4000 * 1000, // 4 Mbps
181
- profileLevel: kVTProfileLevel_H264_Baseline_3_1 as String,
182
- scalingMode: .trim,
183
- bitRateMode: .average,
184
- maxKeyFrameIntervalDuration: 2,
185
- allowFrameReordering: nil,
186
- isHardwareEncoderEnabled: true
187
- )
188
- stream.videoSettings = videoSettings
189
-
190
- self.currentStreamStatus = "previewReady"
191
- sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
192
-
193
- connection.connect(url)
194
- } else {
195
- // Use existing connection
196
- self.rtmpConnection?.connect(url)
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.currentStreamStatus = "publishing"
202
- sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
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
- self.rtmpStream?.publish(streamKey)
205
- print("[ExpoSettings] Stream published successfully")
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
- self.currentStreamStatus = "started"
208
- sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
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
- Function("stopStream") { () -> Void in
213
- Task {
214
- print("[ExpoSettings] stopStream called")
474
+ tick()
475
+ }
215
476
 
216
- // Primeiro pare a publicação (se estiver publicando)
217
- if let stream = self.rtmpStream {
218
- print("[ExpoSettings] Stopping stream publication")
219
- stream.close()
477
+ // MARK: - Stats
220
478
 
221
- // Desanexa a câmera e o áudio para liberar recursos
222
- stream.attachCamera(nil)
223
- stream.attachAudio(nil)
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
- // Depois feche a conexão RTMP
227
- if let connection = self.rtmpConnection {
228
- print("[ExpoSettings] Closing RTMP connection")
229
- connection.close()
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
- // Limpe as referências
233
- self.rtmpStream = nil
234
- self.rtmpConnection = nil
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
- print("[ExpoSettings] Stream and connection closed and resources released")
538
+ var elapsed: TimeInterval = 0
539
+ var stableZeroCount = 0
237
540
 
238
- self.currentStreamStatus = "stopped"
239
- sendEvent("onStreamStatus", ["status": self.currentStreamStatus])
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
- public func attachStream(_ stream: RTMPStream) {
28
- hkView.attachStream(stream)
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.11",
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
  }