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:
Emiliano Frascaro
2026-06-19 18:48:29 +02:00
co-authored by Cursor
parent ed0f913138
commit a303a94671
16 changed files with 1092 additions and 319 deletions
@@ -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)