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.
@@ -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
- View(ExpoSettingsView.self) {}
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("initializePreview") { () -> Void in
23
- self.setStatus("previewInitializing")
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
- self.configureAudioSession()
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
- let connection = RTMPConnection()
28
- self.rtmpConnection = connection
100
+ Function("getStreamTiming") { () -> [String: Any] in
101
+ var result: [String: Any] = [:]
102
+ let fmt = ISO8601DateFormatter()
29
103
 
30
- let stream = RTMPStream(connection: connection)
31
- self.rtmpStream = stream
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.configureStream(stream)
34
- self.attachAudioIfAvailable(stream)
35
- self.attachFrontCamera(stream)
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.setStatus("previewReady")
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.setStatus("connecting")
43
- print("[ExpoSettings] Publishing to: \(url) key: \(streamKey)")
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
- // Se não existe stream/connection, cria e aplica TODA a config
46
- if self.rtmpConnection == nil || self.rtmpStream == nil {
47
- let connection = RTMPConnection()
48
- self.rtmpConnection = connection
135
+ // MARK: - Helpers
49
136
 
50
- let stream = RTMPStream(connection: connection)
51
- self.rtmpStream = stream
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
- self.configureStream(stream)
54
- self.attachAudioIfAvailable(stream)
55
- self.attachFrontCamera(stream)
56
- self.attachPreviewIfAvailable(stream)
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
- self.rtmpConnection?.connect(url)
60
- self.setStatus("connected")
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
- self.setStatus("publishing")
63
- self.rtmpStream?.publish(streamKey)
168
+ private func requestAVPermissions(completion: @escaping (Bool) -> Void) {
169
+ let group = DispatchGroup()
170
+ var camOK = false
171
+ var micOK = false
64
172
 
65
- self.setStatus("started")
173
+ group.enter()
174
+ AVCaptureDevice.requestAccess(for: .video) { granted in
175
+ camOK = granted
176
+ group.leave()
66
177
  }
67
178
 
68
- Function("stopStream") { () -> Void in
69
- print("[ExpoSettings] stopStream called")
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
- if let stream = self.rtmpStream {
72
- stream.close()
73
- stream.attachCamera(nil)
74
- stream.attachAudio(nil)
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
- if let connection = self.rtmpConnection {
78
- connection.close()
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
- self.rtmpStream = nil
82
- self.rtmpConnection = nil
220
+ let connection = RTMPConnection()
221
+ let stream = RTMPStream(connection: connection)
83
222
 
84
- self.setStatus("stopped")
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
- // MARK: - Internals
238
+ self.rtmpConnection = connection
239
+ self.rtmpStream = stream
89
240
 
90
- private func setStatus(_ status: String) {
91
- self.currentStreamStatus = status
92
- sendEvent("onStreamStatus", ["status": status])
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
- private func configureAudioSession() {
96
- let session = AVAudioSession.sharedInstance()
97
- do {
98
- try session.setCategory(
99
- .playAndRecord,
100
- mode: .default,
101
- options: [.defaultToSpeaker, .allowBluetooth]
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
- try session.setActive(true)
104
- } catch {
105
- print("[ExpoSettings] AVAudioSession error:", error)
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
- private func configureStream(_ stream: RTMPStream) {
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 attachAudioIfAvailable(_ stream: RTMPStream) {
142
- if let audioDevice = AVCaptureDevice.default(for: .audio) {
143
- print("[ExpoSettings] Attaching audio")
144
- stream.attachAudio(audioDevice)
145
- } else {
146
- print("[ExpoSettings] No audio device found")
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
- private func attachFrontCamera(_ stream: RTMPStream) {
151
- guard let camera = AVCaptureDevice.default(
152
- .builtInWideAngleCamera,
153
- for: .video,
154
- position: .front
155
- ) else {
156
- print("[ExpoSettings] No front camera found")
157
- return
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
- print("[ExpoSettings] Attaching front camera")
404
+ var checks = 0
405
+ let maxChecks = 200 // 20s (200 x 100ms)
406
+ let interval: TimeInterval = 0.1
161
407
 
162
- stream.attachCamera(camera) { videoUnit, error in
163
- guard let unit = videoUnit else {
164
- print("[ExpoSettings] attachCamera error:", error?.localizedDescription ?? "unknown")
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
- unit.isVideoMirrored = true
169
- unit.videoOrientation = .portrait // <-- GARANTA que está .portrait (não .po)
170
- unit.preferredVideoStabilizationMode = .standard
171
- unit.colorFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
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
- private func attachPreviewIfAvailable(_ stream: RTMPStream) {
176
- DispatchQueue.main.async {
177
- if let preview = ExpoSettingsView.current {
178
- print("[ExpoSettings] Attaching stream to preview")
179
- preview.attachStream(stream)
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
- print("[ExpoSettings] Preview not available yet")
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
- 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.10",
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
  }