blix-expo-settings 0.1.14 → 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.
- package/ios/ExpoSettingsModule.swift +312 -218
- package/package.json +1 -1
|
@@ -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
|
|
10
|
-
private
|
|
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 =
|
|
16
|
-
private let audioBitrate =
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
52
|
+
Function("getStreamStatus") { self.currentStreamStatus }
|
|
32
53
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
Function("
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
102
|
+
private func setupStream() {
|
|
103
|
+
cleanupStream()
|
|
46
104
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
105
|
+
let connection = RTMPConnection()
|
|
106
|
+
let stream = RTMPStream(connection: connection)
|
|
50
107
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
self.setStatus("idle")
|
|
58
|
-
}
|
|
59
|
-
}
|
|
113
|
+
rtmpConnection = connection
|
|
114
|
+
rtmpStream = stream
|
|
60
115
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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:
|
|
137
|
-
scalingMode: .
|
|
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:
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
stream.attachCamera(camera) { unit, _ in
|
|
156
|
+
unit?.isVideoMirrored = true
|
|
157
|
+
unit?.videoOrientation = .portrait
|
|
158
|
+
}
|
|
159
|
+
stream.attachAudio(mic) { _, _ in }
|
|
148
160
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
177
|
+
private func publishStream(url: String, streamKey: String) {
|
|
178
|
+
sessionId = UUID()
|
|
172
179
|
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
var cleanUrl = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
181
|
+
while cleanUrl.hasSuffix("/") { cleanUrl.removeLast() }
|
|
175
182
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
])
|
|
183
|
+
guard let connection = rtmpConnection, rtmpStream != nil else {
|
|
184
|
+
setStatus("error")
|
|
185
|
+
return
|
|
180
186
|
}
|
|
181
187
|
|
|
182
|
-
|
|
188
|
+
pendingPublish = (cleanUrl, streamKey)
|
|
189
|
+
setStatus("connecting")
|
|
190
|
+
connection.connect(cleanUrl)
|
|
191
|
+
}
|
|
183
192
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
188
|
-
}
|
|
193
|
+
private func handleStatus(_ code: String) {
|
|
194
|
+
let session = sessionId
|
|
195
|
+
guard let stream = rtmpStream else { return }
|
|
189
196
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
+
case "NetStream.Publish.BadName", "NetStream.Publish.Rejected", "NetConnection.Connect.Failed":
|
|
211
|
+
stopStats()
|
|
212
|
+
setStatus("error")
|
|
196
213
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
214
|
+
case "NetConnection.Connect.Closed":
|
|
215
|
+
stopStats()
|
|
216
|
+
if currentStreamStatus != "stopped" { setStatus("stopped") }
|
|
201
217
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
267
|
+
private func stopStats() {
|
|
268
|
+
statsTimer?.invalidate()
|
|
269
|
+
statsTimer = nil
|
|
270
|
+
}
|
|
210
271
|
|
|
211
|
-
|
|
212
|
-
|
|
272
|
+
// MARK: - Stop
|
|
273
|
+
private func stopStream() {
|
|
274
|
+
// Capture the session we are stopping
|
|
275
|
+
let session = sessionId
|
|
213
276
|
|
|
214
|
-
|
|
277
|
+
guard let stream = rtmpStream, let connection = rtmpConnection else {
|
|
278
|
+
setStatus("stopped")
|
|
279
|
+
return
|
|
280
|
+
}
|
|
215
281
|
|
|
216
|
-
|
|
282
|
+
setStatus("stopping")
|
|
283
|
+
stopStats()
|
|
217
284
|
|
|
218
|
-
|
|
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
|
-
|
|
291
|
+
// Stop producing new frames
|
|
292
|
+
stream.attachCamera(nil) { _, _ in }
|
|
293
|
+
stream.attachAudio(nil) { _, _ in }
|
|
221
294
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
256
|
-
|
|
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
|
-
|
|
339
|
+
connection.close()
|
|
259
340
|
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
rtmpStream?.close()
|
|
280
|
-
rtmpStream = nil
|
|
368
|
+
return
|
|
369
|
+
}
|
|
281
370
|
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|