blix-expo-settings 0.1.15 → 0.1.16

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,284 +2,378 @@ import ExpoModulesCore
2
2
  import HaishinKit
3
3
  import AVFoundation
4
4
  import VideoToolbox
5
+ import Logboard
6
+ import UIKit
7
+
8
+ final class RTMPEventObserver: NSObject {
9
+ var onStatus: ((String) -> Void)?
10
+ var onError: ((String) -> Void)?
11
+
12
+ @objc func rtmpStatusHandler(_ notification: Notification) {
13
+ let e = Event.from(notification)
14
+ guard let data = e.data as? [String: Any], let code = data["code"] as? String else { return }
15
+ onStatus?(code)
16
+ }
17
+
18
+ @objc func rtmpErrorHandler(_ notification: Notification) {
19
+ onError?("ioError: \(Event.from(notification))")
20
+ }
21
+ }
5
22
 
6
23
  public class ExpoSettingsModule: Module {
7
24
  private var rtmpConnection: RTMPConnection?
8
25
  private var rtmpStream: RTMPStream?
9
- private var currentStatus: String = "idle"
10
- private var operationStartTime: Date?
26
+ private var currentStreamStatus = "stopped"
27
+ private let rtmpObserver = RTMPEventObserver()
28
+ private var pendingPublish: (url: String, streamKey: String)?
29
+ private var statsTimer: Timer?
11
30
 
12
- // MARK: - Stream Configuration (Portrait 9:16)
13
31
  private let videoWidth = 720
14
32
  private let videoHeight = 1280
15
- private let videoBitrate = 4_000_000
16
- private let audioBitrate = 128_000
33
+ private let videoBitrate = 6_000_000
34
+ private let audioBitrate = 160_000
17
35
  private let frameRate: Float64 = 30
18
- private let gopSeconds: Int32 = 1
19
36
 
20
- private static var audioSessionConfigured = false
37
+ // Timing buffers for Panda Video delays
38
+ private let startBufferSeconds: TimeInterval = 1.5 // Wait after publish before marking started
39
+ private let endBufferSeconds: TimeInterval = 10.0 // Keep streaming after stop requested
40
+ // How long we tolerate the RTMP socket still sending after we detached
41
+ private let drainMaxSeconds: TimeInterval = 25.0 // hard upper bound to wait for remaining data
42
+ private let drainIdleSeconds: TimeInterval = 3.0 // consider drained after 3s with 0 Bps
21
43
 
22
- private static let isoFormatter: ISO8601DateFormatter = {
23
- let f = ISO8601DateFormatter()
24
- f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
25
- return f
26
- }()
44
+ private var sessionId: UUID?
45
+ private var isInitialized = false
27
46
 
28
47
  public func definition() -> ModuleDefinition {
29
48
  Name("ExpoSettings")
49
+ View(ExpoSettingsView.self) {}
50
+ Events("onStreamStatus", "onStreamStats", "onStreamTiming")
30
51
 
31
- // Registra o view component para o admin se enxergar na live
52
+ Function("getStreamStatus") { self.currentStreamStatus }
32
53
 
33
- View(ExpoSettingsView.self) {
34
- // não precisa colocar nada aqui se você não tiver Props
54
+ Function("getStreamInfo") { () -> [String: Any] in
55
+ ["videoWidth": self.videoWidth, "videoHeight": self.videoHeight,
56
+ "aspectRatio": String(format: "%.4f", Double(self.videoWidth) / Double(self.videoHeight)),
57
+ "bitrate": self.videoBitrate, "frameRate": self.frameRate]
35
58
  }
36
59
 
37
- Events("onStreamStatus", "onStreamTiming")
38
-
39
- Function("getStreamStatus") {
40
- return self.currentStatus
60
+ Function("initializePreview") { DispatchQueue.main.async { self.initializePreview() } }
61
+ Function("publishStream") { (url: String, streamKey: String) in DispatchQueue.main.async { self.publishStream(url: url, streamKey: streamKey) } }
62
+ Function("stopStream") { DispatchQueue.main.async { self.stopStream() } }
63
+ }
64
+
65
+ private func setStatus(_ s: String) {
66
+ guard currentStreamStatus != s else { return }
67
+ currentStreamStatus = s
68
+ sendEvent("onStreamStatus", ["status": s, "timestamp": ISO8601DateFormatter().string(from: Date())])
69
+ }
70
+
71
+ private func initializePreview() {
72
+ LBLogger.with("com.haishinkit.HaishinKit").level = .trace
73
+ setStatus("previewInitializing")
74
+
75
+ let group = DispatchGroup()
76
+ var camOK = false, micOK = false
77
+
78
+ group.enter()
79
+ AVCaptureDevice.requestAccess(for: .video) { camOK = $0; group.leave() }
80
+ group.enter()
81
+ AVCaptureDevice.requestAccess(for: .audio) { micOK = $0; group.leave() }
82
+
83
+ group.notify(queue: .main) { [weak self] in
84
+ guard let self, camOK, micOK else { self?.setStatus("error"); return }
85
+
86
+ do {
87
+ let session = AVAudioSession.sharedInstance()
88
+ try session.setCategory(.playAndRecord, mode: .videoRecording, options: [.defaultToSpeaker, .allowBluetooth])
89
+ try session.setPreferredSampleRate(48000)
90
+ try session.setActive(true)
91
+ } catch {}
92
+
93
+ self.setupStream()
94
+ self.isInitialized = true
95
+
96
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
97
+ self?.setStatus("previewReady")
98
+ }
41
99
  }
100
+ }
42
101
 
43
- Function("initializePreview") {
44
- Task { await self.initializePreview() }
45
- }
102
+ private func setupStream() {
103
+ cleanupStream()
46
104
 
47
- Function("publishStream") { (url: String, streamKey: String) in
48
- Task { await self.publishStream(url: url, streamKey: streamKey) }
49
- }
105
+ let connection = RTMPConnection()
106
+ let stream = RTMPStream(connection: connection)
50
107
 
51
- Function("stopStream") {
52
- Task { await self.stopStream() }
53
- }
108
+ connection.addEventListener(.rtmpStatus, selector: #selector(RTMPEventObserver.rtmpStatusHandler(_:)), observer: rtmpObserver)
109
+ connection.addEventListener(.ioError, selector: #selector(RTMPEventObserver.rtmpErrorHandler(_:)), observer: rtmpObserver)
110
+ stream.addEventListener(.rtmpStatus, selector: #selector(RTMPEventObserver.rtmpStatusHandler(_:)), observer: rtmpObserver)
111
+ stream.addEventListener(.ioError, selector: #selector(RTMPEventObserver.rtmpErrorHandler(_:)), observer: rtmpObserver)
54
112
 
55
- Function("forceCleanup") {
56
- self.cleanup()
57
- self.setStatus("idle")
58
- }
59
- }
113
+ rtmpConnection = connection
114
+ rtmpStream = stream
60
115
 
61
- private func setStatus(_ status: String) {
62
- guard currentStatus != status else { return }
63
-
64
- print("[ExpoSettings] \(currentStatus) → \(status)")
65
- currentStatus = status
66
-
67
- sendEvent("onStreamStatus", [
68
- "status": status,
69
- "timestamp": Self.isoFormatter.string(from: Date())
70
- ])
71
- }
72
-
73
- private func setupAudioSession() -> Bool {
74
-
75
- if Self.audioSessionConfigured { return true }
76
-
77
- do {
78
- let session = AVAudioSession.sharedInstance()
79
-
80
- try session.setCategory(
81
- .playAndRecord,
82
- mode: .default,
83
- options: [.defaultToSpeaker, .allowBluetooth]
84
- )
116
+ rtmpObserver.onStatus = { [weak self] code in self?.handleStatus(code) }
117
+ rtmpObserver.onError = { [weak self] _ in self?.setStatus("error") }
85
118
 
86
- try session.setActive(true)
87
-
88
- Self.audioSessionConfigured = true
89
- print("[ExpoSettings] Audio session ready")
90
- return true
91
-
92
- } catch {
93
- print("[ExpoSettings] Audio session error:", error)
94
- return false
95
- }
96
- }
97
-
98
- private func initializePreview() async {
99
-
100
- print("[ExpoSettings] initializePreview")
101
- operationStartTime = Date()
102
-
103
- cleanup()
104
- try? await Task.sleep(nanoseconds: 200_000_000)
105
-
106
- setStatus("previewInitializing")
107
-
108
- guard setupAudioSession() else {
109
- setStatus("error")
110
- return
111
- }
112
-
113
- let connection = RTMPConnection()
114
- // 2) Criar RTMPStream, mas não publica pro servidor ainda
115
- let stream = RTMPStream(connection: connection)
116
- self.rtmpConnection = connection
117
- self.rtmpStream = stream
118
-
119
- // ---------- Stream Base ----------
120
- stream.sessionPreset = .hd1280x720
121
- stream.frameRate = frameRate
122
- stream.videoOrientation = .portrait
123
- stream.configuration { captureSession in
124
- captureSession.automaticallyConfiguresApplicationAudioSession = true
125
- }
126
-
127
- // ---------- Audio ----------
128
- stream.audioSettings = AudioCodecSettings(
129
- bitRate: audioBitrate
130
- )
131
-
132
- // ---------- Video ----------
133
- stream.videoSettings = VideoCodecSettings(
134
- videoSize: .init(width: videoWidth, height: videoHeight),
119
+ if #available(iOS 16.0, *) {
120
+ stream.videoSettings = VideoCodecSettings(
121
+ videoSize: CGSize(width: videoWidth, height: videoHeight),
135
122
  bitRate: videoBitrate,
136
- profileLevel: kVTProfileLevel_H264_Main_4_1 as String,
137
- scalingMode: .letterbox,
123
+ profileLevel: kVTProfileLevel_H264_High_4_1 as String,
124
+ scalingMode: .trim,
125
+ bitRateMode: .constant,
126
+ maxKeyFrameIntervalDuration: 2,
127
+ allowFrameReordering: nil,
128
+ isHardwareEncoderEnabled: true
129
+ )
130
+ } else {
131
+ // Fallback on earlier versions
132
+ stream.videoSettings = VideoCodecSettings(
133
+ videoSize: CGSize(width: videoWidth, height: videoHeight),
134
+ bitRate: videoBitrate,
135
+ profileLevel: kVTProfileLevel_H264_High_4_1 as String,
136
+ scalingMode: .trim,
138
137
  bitRateMode: .average,
139
- maxKeyFrameIntervalDuration: gopSeconds,
138
+ maxKeyFrameIntervalDuration: 2,
140
139
  allowFrameReordering: nil,
141
140
  isHardwareEncoderEnabled: true
142
- )
141
+ )
142
+ }
143
+ stream.frameRate = frameRate
144
+
145
+ var audioSettings = AudioCodecSettings()
146
+ audioSettings.bitRate = audioBitrate
147
+ stream.audioSettings = audioSettings
148
+
149
+ guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front),
150
+ let mic = AVCaptureDevice.default(for: .audio) else {
151
+ setStatus("error")
152
+ return
153
+ }
143
154
 
144
- // ---------- Attach Audio ----------
145
- if let mic = AVCaptureDevice.default(for: .audio) {
146
- stream.attachAudio(mic)
147
- }
155
+ stream.attachCamera(camera) { unit, _ in
156
+ unit?.isVideoMirrored = true
157
+ unit?.videoOrientation = .portrait
158
+ }
159
+ stream.attachAudio(mic) { _, _ in }
148
160
 
149
- // ---------- Attach Camera ----------
150
- if let cam = AVCaptureDevice.default(
151
- .builtInWideAngleCamera,
152
- for: .video,
153
- position: .front
154
- ) {
155
- stream.attachCamera(cam) { unit, _ in
156
- guard let unit else { return }
157
- unit.videoOrientation = .portrait
158
- unit.isVideoMirrored = true
159
- unit.preferredVideoStabilizationMode = .standard
160
- unit.colorFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
161
- }
162
- }
161
+ ExpoSettingsView.current?.attachStream(stream)
162
+ }
163
163
 
164
- // ---------- Preview ----------
165
- if let preview = await ExpoSettingsView.current {
166
- await preview.attachStream(stream)
167
- } else {
168
- print("[ExpoSettings] ERROR: Preview view not found during publish!")
169
- }
164
+ private func cleanupStream() {
165
+ if let c = rtmpConnection {
166
+ c.removeEventListener(.rtmpStatus, selector: #selector(RTMPEventObserver.rtmpStatusHandler(_:)), observer: rtmpObserver)
167
+ c.removeEventListener(.ioError, selector: #selector(RTMPEventObserver.rtmpErrorHandler(_:)), observer: rtmpObserver)
168
+ }
169
+ if let s = rtmpStream {
170
+ s.removeEventListener(.rtmpStatus, selector: #selector(RTMPEventObserver.rtmpStatusHandler(_:)), observer: rtmpObserver)
171
+ s.removeEventListener(.ioError, selector: #selector(RTMPEventObserver.rtmpErrorHandler(_:)), observer: rtmpObserver)
172
+ }
173
+ rtmpStream = nil
174
+ rtmpConnection = nil
175
+ }
170
176
 
171
- let ms = Int(Date().timeIntervalSince(operationStartTime!) * 1000)
177
+ private func publishStream(url: String, streamKey: String) {
178
+ sessionId = UUID()
172
179
 
173
- print("[ExpoSettings] Preview ready in \(ms)ms")
174
- setStatus("previewReady")
180
+ var cleanUrl = url.trimmingCharacters(in: .whitespacesAndNewlines)
181
+ while cleanUrl.hasSuffix("/") { cleanUrl.removeLast() }
175
182
 
176
- sendEvent("onStreamTiming", [
177
- "event": "previewReady",
178
- "durationMs": ms
179
- ])
183
+ guard let connection = rtmpConnection, rtmpStream != nil else {
184
+ setStatus("error")
185
+ return
180
186
  }
181
187
 
182
- private func publishStream(url: String, streamKey: String) async {
188
+ pendingPublish = (cleanUrl, streamKey)
189
+ setStatus("connecting")
190
+ connection.connect(cleanUrl)
191
+ }
183
192
 
184
- guard let connection = rtmpConnection,
185
- let stream = rtmpStream else {
186
- setStatus("error")
187
- return
188
- }
193
+ private func handleStatus(_ code: String) {
194
+ let session = sessionId
195
+ guard let stream = rtmpStream else { return }
189
196
 
190
- operationStartTime = Date()
191
- setStatus("connecting")
197
+ switch code {
198
+ case "NetConnection.Connect.Success":
199
+ setStatus("connected")
200
+ if let p = pendingPublish {
201
+ pendingPublish = nil
202
+ setStatus("publishing")
203
+ stream.publish(p.streamKey, type: .live)
204
+ }
192
205
 
193
- self.rtmpConnection?.connect(url)
206
+ case "NetStream.Publish.Start":
207
+ // Data is now flowing to Panda - wait for buffer before marking started
208
+ waitForStartBuffer(session: session)
194
209
 
195
- let deadline = Date().addingTimeInterval(10)
210
+ case "NetStream.Publish.BadName", "NetStream.Publish.Rejected", "NetConnection.Connect.Failed":
211
+ stopStats()
212
+ setStatus("error")
196
213
 
197
- while Date() < deadline {
198
- if connection.connected { break }
199
- try? await Task.sleep(nanoseconds: 50_000_000)
200
- }
214
+ case "NetConnection.Connect.Closed":
215
+ stopStats()
216
+ if currentStreamStatus != "stopped" { setStatus("stopped") }
201
217
 
202
- guard connection.connected else {
203
- print("[ExpoSettings] Connect timeout")
204
- setStatus("error")
205
- return
218
+ default: break
219
+ }
220
+ }
221
+
222
+ private func waitForStartBuffer(session: UUID?) {
223
+ guard let connection = rtmpConnection, let stream = rtmpStream else { return }
224
+
225
+ // Monitor until we confirm data is actually flowing
226
+ var checks = 0
227
+ let maxChecks = Int(startBufferSeconds * 10) // Check every 100ms
228
+ var dataConfirmed = false
229
+
230
+ func checkData() {
231
+ guard self.sessionId == session else { return }
232
+ checks += 1
233
+
234
+ let bytesOut = connection.currentBytesOutPerSecond
235
+ let fps = stream.currentFPS
236
+
237
+ if bytesOut > 0 && fps > 0 {
238
+ dataConfirmed = true
239
+ }
240
+
241
+ if checks >= maxChecks {
242
+ if dataConfirmed {
243
+ self.sendEvent("onStreamTiming", ["event": "started", "timestamp": ISO8601DateFormatter().string(from: Date())])
244
+ self.setStatus("started")
245
+ self.startStats()
246
+ } else {
247
+ // No data flowing after buffer - likely an error
248
+ self.setStatus("error")
206
249
  }
250
+ return
251
+ }
252
+
253
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { checkData() }
254
+ }
255
+
256
+ checkData()
257
+ }
258
+
259
+ private func startStats() {
260
+ stopStats()
261
+ statsTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
262
+ guard let self, let c = self.rtmpConnection, let s = self.rtmpStream else { return }
263
+ self.sendEvent("onStreamStats", ["fps": s.currentFPS, "bps": c.currentBytesOutPerSecond * 8, "timestamp": ISO8601DateFormatter().string(from: Date())])
264
+ }
265
+ }
207
266
 
208
- setStatus("connected")
209
- try? await Task.sleep(nanoseconds: 150_000_000)
267
+ private func stopStats() {
268
+ statsTimer?.invalidate()
269
+ statsTimer = nil
270
+ }
210
271
 
211
- setStatus("publishing")
212
- stream.publish(streamKey)
272
+ // MARK: - Stop
273
+ private func stopStream() {
274
+ // Capture the session we are stopping
275
+ let session = sessionId
213
276
 
214
- try? await Task.sleep(nanoseconds: 200_000_000)
277
+ guard let stream = rtmpStream, let connection = rtmpConnection else {
278
+ setStatus("stopped")
279
+ return
280
+ }
215
281
 
216
- let ms = Int(Date().timeIntervalSince(operationStartTime!) * 1000)
282
+ setStatus("stopping")
283
+ stopStats()
217
284
 
218
- print("[ExpoSettings] STREAM STARTED in \(ms)ms")
285
+ // 1) Keep capturing for a bit AFTER the user taps stop,
286
+ // so Panda's "cut tail" window hits this extra content,
287
+ // not the actual last seconds of the live.
288
+ DispatchQueue.main.asyncAfter(deadline: .now() + endBufferSeconds) { [weak self] in
289
+ guard let self = self, self.sessionId == session else { return }
219
290
 
220
- setStatus("started")
291
+ // Stop producing new frames
292
+ stream.attachCamera(nil) { _, _ in }
293
+ stream.attachAudio(nil) { _, _ in }
221
294
 
222
- sendEvent("onStreamTiming", [
223
- "event": "firstDataSent",
224
- "delayMs": ms,
225
- "timestamp": Self.isoFormatter.string(from: Date())
226
- ])
295
+ // 2) Now drain remaining bytes on the RTMP socket before closing
296
+ self.flushAndClose(stream: stream, connection: connection, session: session)
227
297
  }
228
-
229
- private func stopStream() async {
230
-
231
- print("[ExpoSettings] stopStream")
232
-
233
- guard let stream = rtmpStream,
234
- let connection = rtmpConnection else {
235
- cleanup()
236
- setStatus("stopped")
237
- return
298
+ }
299
+
300
+ private func flushAndClose(stream: RTMPStream,
301
+ connection: RTMPConnection,
302
+ session: UUID?) {
303
+ let start = Date()
304
+ var idleStart: Date? = nil
305
+
306
+ func tick() {
307
+ // If a new stream started meanwhile, abort this drain
308
+ guard self.sessionId == session else { return }
309
+
310
+ let now = Date()
311
+ let elapsed = now.timeIntervalSince(start)
312
+
313
+ let bps = Double(connection.currentBytesOutPerSecond)
314
+
315
+ if bps > 0 {
316
+ // Still sending data – reset idle timer
317
+ idleStart = nil
318
+ } else {
319
+ // 0 Bps – start or continue idle period
320
+ if idleStart == nil {
321
+ idleStart = now
238
322
  }
323
+ }
239
324
 
240
- operationStartTime = Date()
241
- setStatus("stopping")
242
-
243
- // Stop capture
244
- stream.attachCamera(nil)
245
- stream.attachAudio(nil)
246
-
247
- // Flush encoder (GOP + 0.5s)
248
- let flushNs = UInt64(gopSeconds) * 1_000_000_000 + 500_000_000
249
- try? await Task.sleep(nanoseconds: flushNs)
325
+ let idle = idleStart.map { now.timeIntervalSince($0) } ?? 0
250
326
 
251
- // Close stream
327
+ // Conditions to consider the stream fully drained:
328
+ // - we have been idle (0 Bps) for drainIdleSeconds
329
+ // OR
330
+ // - we hit the absolute max wait (drainMaxSeconds)
331
+ if idle >= drainIdleSeconds || elapsed >= drainMaxSeconds {
332
+ // Close stream first
252
333
  stream.close()
253
- try? await Task.sleep(nanoseconds: 300_000_000)
254
334
 
255
- // Close socket
256
- connection.close()
335
+ // Give HaishinKit a tiny moment to flush internal state
336
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
337
+ guard let self = self else { return }
257
338
 
258
- cleanup()
339
+ connection.close()
259
340
 
260
- let ms = Int(Date().timeIntervalSince(operationStartTime!) * 1000)
341
+ // And a little extra before we emit final status / reset preview
342
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
343
+ guard let self = self, self.sessionId == session else { return }
261
344
 
262
- print("[ExpoSettings] STOPPED in \(ms)ms")
263
-
264
- setStatus("stopped")
265
-
266
- sendEvent("onStreamTiming", [
267
- "event": "shutdownComplete",
268
- "totalDurationMs": ms,
269
- "timestamp": Self.isoFormatter.string(from: Date())
270
- ])
271
- }
272
-
273
- private func cleanup() {
345
+ self.sendEvent(
346
+ "onStreamTiming",
347
+ [
348
+ "event": "stopped",
349
+ "timestamp": ISO8601DateFormatter().string(from: Date())
350
+ ]
351
+ )
274
352
 
275
- print("[ExpoSettings] Cleanup")
353
+ self.pendingPublish = nil
354
+ self.setStatus("stopped")
355
+
356
+ // Re-arm preview for the next live
357
+ if self.isInitialized {
358
+ self.setupStream()
359
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
360
+ if self?.currentStreamStatus == "stopped" {
361
+ self?.setStatus("previewReady")
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
276
367
 
277
- rtmpStream?.attachCamera(nil)
278
- rtmpStream?.attachAudio(nil)
279
- rtmpStream?.close()
280
- rtmpStream = nil
368
+ return
369
+ }
281
370
 
282
- rtmpConnection?.close()
283
- rtmpConnection = nil
371
+ // Keep checking every 200 ms
372
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
373
+ tick()
374
+ }
284
375
  }
285
- }
376
+
377
+ tick()
378
+ }
379
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blix-expo-settings",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "LiveStream",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",