Fix HLS browser in prod e stabilizza broadcast RTMP su iOS.
Il proxy Rails gestisce il redirect MediaMTX su /live/match_* e riscrive le playlist; edge nginx reindirizza /live/ sotto /hls/. Su iOS, SessionBuilder HaishinKit, pipeline mixer corretta e refresh token. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ed0f913138
commit
a303a94671
@@ -3,23 +3,20 @@ import Foundation
|
||||
import HaishinKit
|
||||
import RTMPHaishinKit
|
||||
import UIKit
|
||||
import VideoToolbox
|
||||
|
||||
struct RTMPPublishTarget: Sendable {
|
||||
let connectURL: String
|
||||
let streamName: String
|
||||
}
|
||||
private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
|
||||
let mamimumVideoBitRate: Int = 0
|
||||
let mamimumAudioBitRate: Int = 0
|
||||
let onUpdate: @Sendable (Int) -> Void
|
||||
|
||||
enum RTMPUrlParser {
|
||||
static func parse(_ urlString: String) -> RTMPPublishTarget? {
|
||||
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
|
||||
let pathParts = url.path.split(separator: "/").map(String.init)
|
||||
guard pathParts.count >= 2 else { return nil }
|
||||
let app = pathParts.dropLast().joined(separator: "/")
|
||||
let streamName = pathParts.last ?? ""
|
||||
var connect = "rtmp://\(url.host ?? "")"
|
||||
if let port = url.port { connect += ":\(port)" }
|
||||
connect += "/\(app)"
|
||||
return RTMPPublishTarget(connectURL: connect, streamName: streamName)
|
||||
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
|
||||
switch event {
|
||||
case .status(let report), .publishInsufficientBWOccured(let report):
|
||||
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||
case .reset:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +25,27 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
@Published private(set) var metrics = BroadcastMetrics()
|
||||
|
||||
let mixer = MediaMixer()
|
||||
let connection = RTMPConnection()
|
||||
let stream: RTMPStream
|
||||
private let overlayRenderer = OverlayRenderer()
|
||||
|
||||
private var config: BroadcastConfig?
|
||||
private var phase: BroadcastPhase = .idle
|
||||
private var metricsListener: ((BroadcastMetrics) -> Void)?
|
||||
private var reconnectAttempts = 0
|
||||
private var publishTask: Task<Void, Never>?
|
||||
private var readyStateTask: Task<Void, Never>?
|
||||
private var pipelineConfigured = false
|
||||
private var isPortrait = false
|
||||
private var orientationObserver: NSObjectProtocol?
|
||||
private weak var previewView: MTHKView?
|
||||
private var previewAttached = false
|
||||
private var publishPending = false
|
||||
private var publishInFlight = false
|
||||
private var rtmpSession: (any Session)?
|
||||
private static var rtmpFactoryRegistered = false
|
||||
|
||||
init() {
|
||||
stream = RTMPStream(connection: connection)
|
||||
private static func ensureRTMPFactoryRegistered() async {
|
||||
guard !rtmpFactoryRegistered else { return }
|
||||
await SessionBuilderFactory.shared.register(RTMPSessionFactory())
|
||||
rtmpFactoryRegistered = true
|
||||
}
|
||||
|
||||
func setMetricsListener(_ block: ((BroadcastMetrics) -> Void)?) {
|
||||
@@ -53,50 +57,71 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
overlayRenderer.update(state: state, isPortrait: isPortrait)
|
||||
}
|
||||
|
||||
/// Anteprima locale: output del mixer (come HaishinKit PublishViewModel), non dello stream RTMP.
|
||||
func bindPreview(to view: MTHKView) async {
|
||||
await stream.addOutput(view)
|
||||
previewView = view
|
||||
guard pipelineConfigured, !previewAttached else { return }
|
||||
await mixer.addOutput(view)
|
||||
previewAttached = true
|
||||
if publishPending {
|
||||
await startPendingPublish()
|
||||
}
|
||||
}
|
||||
|
||||
func preparePreview(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishPending = false
|
||||
try await configurePipeline(config)
|
||||
setPhase(.preview)
|
||||
}
|
||||
|
||||
func startBroadcast(config: BroadcastConfig) async throws {
|
||||
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
||||
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
publishPending = true
|
||||
try await configurePipeline(config)
|
||||
setPhase(.connecting)
|
||||
try await Task.sleep(nanoseconds: 350_000_000)
|
||||
try await publish(config: config)
|
||||
await startPendingPublish()
|
||||
}
|
||||
|
||||
func startBroadcast(config: BroadcastConfig) async throws {
|
||||
try await prepareForBroadcast(config: config)
|
||||
}
|
||||
|
||||
func pauseBroadcast() async {
|
||||
try? await stream.close()
|
||||
publishPending = false
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
await teardownRTMP(keepPreview: true)
|
||||
setPhase(.paused)
|
||||
}
|
||||
|
||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishPending = true
|
||||
if !pipelineConfigured {
|
||||
try await configurePipeline(config)
|
||||
}
|
||||
setPhase(.connecting)
|
||||
try await publish(config: config)
|
||||
await startPendingPublish()
|
||||
}
|
||||
|
||||
func stopBroadcast() async {
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
publishPending = false
|
||||
publishInFlight = false
|
||||
stopOrientationMonitoring()
|
||||
await teardownRTMP(keepPreview: false)
|
||||
if let previewView, previewAttached {
|
||||
await mixer.removeOutput(previewView)
|
||||
}
|
||||
previewAttached = false
|
||||
previewView = nil
|
||||
overlayRenderer.detach()
|
||||
try? await stream.close()
|
||||
try? await connection.close()
|
||||
try? await mixer.stopRunning()
|
||||
pipelineConfigured = false
|
||||
setPhase(.idle)
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
func release() async {
|
||||
@@ -126,19 +151,22 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||
try await mixer.attachVideo(camera, track: 0)
|
||||
}
|
||||
try await mixer.addOutput(stream)
|
||||
try await mixer.startRunning()
|
||||
overlayRenderer.attach(mixer: mixer, width: config.width, height: config.height, isPortrait: config.portrait)
|
||||
await applyVideoOrientation()
|
||||
startOrientationMonitoring()
|
||||
|
||||
var streamSettings = await stream.videoSettings
|
||||
streamSettings.videoSize = .init(width: config.width, height: config.height)
|
||||
streamSettings.bitRate = config.videoBitrate
|
||||
streamSettings.maxKeyFrameIntervalDuration = 2
|
||||
try await stream.setVideoSettings(streamSettings)
|
||||
var audioSettings = await stream.audioSettings
|
||||
audioSettings.bitRate = config.audioBitrate
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
try await mixer.startRunning()
|
||||
overlayRenderer.attach(
|
||||
mixer: mixer,
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
isPortrait: isPortrait
|
||||
)
|
||||
pipelineConfigured = true
|
||||
|
||||
if let previewView, !previewAttached {
|
||||
await mixer.addOutput(previewView)
|
||||
previewAttached = true
|
||||
}
|
||||
}
|
||||
|
||||
private func configureScreenSize(width: Int, height: Int) async {
|
||||
@@ -148,23 +176,175 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
}.value
|
||||
}
|
||||
|
||||
private func startPendingPublish() async {
|
||||
guard publishPending, !publishInFlight else { return }
|
||||
publishTask?.cancel()
|
||||
publishTask = Task {
|
||||
await waitForPreviewThenPublish()
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForPreviewThenPublish() async {
|
||||
guard publishPending, let config else { return }
|
||||
publishInFlight = true
|
||||
defer { publishInFlight = false }
|
||||
|
||||
for _ in 0..<80 {
|
||||
if Task.isCancelled { return }
|
||||
if previewAttached { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
guard previewAttached else {
|
||||
publishPending = false
|
||||
setPhase(.error, error: "Anteprima camera non pronta")
|
||||
return
|
||||
}
|
||||
|
||||
setPhase(.connecting)
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard publishPending else { return }
|
||||
|
||||
do {
|
||||
try await publish(config: config)
|
||||
publishPending = false
|
||||
} catch {
|
||||
publishPending = false
|
||||
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
|
||||
setPhase(.error, error: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(config: BroadcastConfig) async throws {
|
||||
guard let target = RTMPUrlParser.parse(config.rtmpUrl) else {
|
||||
guard let url = URL(string: config.rtmpUrl) else {
|
||||
throw APIError.http(400, "URL RTMP non valido")
|
||||
}
|
||||
try await connection.connect(target.connectURL)
|
||||
try await stream.publish(target.streamName)
|
||||
reconnectAttempts = 0
|
||||
await Self.ensureRTMPFactoryRegistered()
|
||||
await applyVideoOrientation()
|
||||
await teardownRTMP(keepPreview: true)
|
||||
|
||||
let session = try await SessionBuilderFactory.shared.make(url)
|
||||
.setMode(.publish)
|
||||
.build()
|
||||
guard let session else {
|
||||
throw APIError.http(500, "Impossibile creare sessione RTMP")
|
||||
}
|
||||
|
||||
let stream = await session.stream
|
||||
try await applyCodecSettings(to: stream, config: config)
|
||||
await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.metrics = BroadcastMetrics(
|
||||
phase: self.phase,
|
||||
bitrateKbps: kbps,
|
||||
fps: self.config?.fps ?? self.metrics.fps,
|
||||
connected: self.phase == .live,
|
||||
lastError: self.metrics.lastError
|
||||
)
|
||||
self.metricsListener?(self.metrics)
|
||||
}
|
||||
})
|
||||
await mixer.addOutput(stream)
|
||||
rtmpSession = session
|
||||
observeReadyState(session)
|
||||
|
||||
try await session.connect { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self, self.phase == .live else { return }
|
||||
self.setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
}
|
||||
}
|
||||
setPhase(.live)
|
||||
}
|
||||
|
||||
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
|
||||
var videoSettings = await stream.videoSettings
|
||||
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
||||
videoSettings.bitRate = config.videoBitrate
|
||||
videoSettings.maxKeyFrameIntervalDuration = 2
|
||||
videoSettings.profileLevel = kVTProfileLevel_H264_Baseline_3_1 as String
|
||||
videoSettings.expectedFrameRate = Double(config.fps)
|
||||
try await stream.setVideoSettings(videoSettings)
|
||||
|
||||
var audioSettings = await stream.audioSettings
|
||||
audioSettings = AudioCodecSettings(
|
||||
bitRate: config.audioBitrate,
|
||||
downmix: audioSettings.downmix,
|
||||
channelMap: audioSettings.channelMap,
|
||||
sampleRate: 48_000,
|
||||
format: audioSettings.format
|
||||
)
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
}
|
||||
|
||||
private func observeReadyState(_ session: any Session) {
|
||||
readyStateTask?.cancel()
|
||||
readyStateTask = Task {
|
||||
for await state in await session.readyState {
|
||||
switch state {
|
||||
case .open:
|
||||
setPhase(.live)
|
||||
case .closed where phase == .live:
|
||||
setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func teardownRTMP(keepPreview: Bool) async {
|
||||
readyStateTask?.cancel()
|
||||
readyStateTask = nil
|
||||
if let session = rtmpSession {
|
||||
let stream = await session.stream
|
||||
await mixer.removeOutput(stream)
|
||||
try? await session.close()
|
||||
}
|
||||
rtmpSession = nil
|
||||
if !keepPreview {
|
||||
publishPending = false
|
||||
}
|
||||
}
|
||||
|
||||
private func applyVideoOrientation() async {
|
||||
let orientation = BroadcastVideoOrientation.captureOrientation()
|
||||
await mixer.setVideoOrientation(orientation)
|
||||
let portrait = BroadcastVideoOrientation.isPortraitContent
|
||||
guard portrait != isPortrait else { return }
|
||||
isPortrait = portrait
|
||||
overlayRenderer.refreshOrientation(isPortrait: portrait)
|
||||
}
|
||||
|
||||
private func startOrientationMonitoring() {
|
||||
stopOrientationMonitoring()
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
orientationObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.orientationDidChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.applyVideoOrientation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopOrientationMonitoring() {
|
||||
if let orientationObserver {
|
||||
NotificationCenter.default.removeObserver(orientationObserver)
|
||||
self.orientationObserver = nil
|
||||
}
|
||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||
}
|
||||
|
||||
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
|
||||
phase = newPhase
|
||||
metrics = BroadcastMetrics(
|
||||
phase: newPhase,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
fps: config?.fps ?? metrics.fps,
|
||||
connected: newPhase == .live || newPhase == .reconnecting,
|
||||
connected: newPhase == .live,
|
||||
lastError: error
|
||||
)
|
||||
metricsListener?(metrics)
|
||||
|
||||
Reference in New Issue
Block a user