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
@@ -7,6 +7,10 @@ struct LoginRequest: Encodable {
struct RefreshRequest: Encodable {
let refreshToken: String
enum CodingKeys: String, CodingKey {
case refreshToken = "refresh_token"
}
}
struct LoginResponse: Decodable {
@@ -43,6 +43,8 @@ final class MatchLiveAPI: @unchecked Sendable {
private let session: URLSession
private var accessToken: String?
private let baseURL: URL
/// Chiamato su 401 per rinnovare il token e ritentare la richiesta (una volta).
var tokenRefresher: (() async throws -> String?)?
init(baseURL: URL = URL(string: AppConfig.apiV1)!, session: URLSession = .shared) {
self.baseURL = baseURL
@@ -50,7 +52,7 @@ final class MatchLiveAPI: @unchecked Sendable {
}
func setAccessToken(_ token: String?) {
accessToken = token
accessToken = token?.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Auth
@@ -232,10 +234,20 @@ final class MatchLiveAPI: @unchecked Sendable {
return try await execute(request)
}
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
private func execute<T: Decodable>(_ request: URLRequest, canRefresh: Bool = true) async throws -> T {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidURL }
if http.statusCode == 401 { throw APIError.unauthorized }
if http.statusCode == 401 {
if canRefresh, !isPublicAuthRequest(request), let tokenRefresher {
if let newToken = try await tokenRefresher() {
accessToken = newToken
var retry = request
retry.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
return try await execute(retry, canRefresh: false)
}
}
throw APIError.unauthorized
}
guard (200..<300).contains(http.statusCode) else {
let body = String(data: data, encoding: .utf8)
throw APIError.http(http.statusCode, body)
@@ -249,6 +261,13 @@ final class MatchLiveAPI: @unchecked Sendable {
throw APIError.decoding(error)
}
}
private func isPublicAuthRequest(_ request: URLRequest) -> Bool {
guard let path = request.url?.path else { return false }
return path.hasSuffix("/auth/login")
|| path.hasSuffix("/auth/refresh")
|| path.hasSuffix("/auth/register")
}
}
private struct EmptyResponse: Decodable {}
@@ -18,10 +18,15 @@ final class AppContainer: ObservableObject {
let tokenStore = TokenStore()
self.api = api
self.tokenStore = tokenStore
authRepository = AuthRepository(api: api, tokenStore: tokenStore) { token in
let authRepository = AuthRepository(api: api, tokenStore: tokenStore) { token in
api.setAccessToken(token)
OverlayLogoCache.configure(token: token)
}
self.authRepository = authRepository
api.tokenRefresher = {
guard let stored = authRepository.currentSession else { return nil }
return try await authRepository.refresh(stored.refreshToken).accessToken
}
matchRepository = MatchRepository(api: api, tokenStore: tokenStore)
sessionRepository = SessionRepository(api: api)
scoreRepository = ScoreRepository(api: api)
@@ -28,8 +28,13 @@ final class AuthRepository {
do {
_ = try await api.me()
return stored
} catch let error as APIError {
if case .unauthorized = error {
return try? await refresh(stored.refreshToken)
}
return stored
} catch {
return try? await refresh(stored.refreshToken)
return stored
}
}
@@ -0,0 +1,31 @@
import AVFoundation
import UIKit
/// Allinea l'orientamento del sensore camera all'interfaccia (broadcast sempre landscape).
enum BroadcastVideoOrientation {
@MainActor
static func captureOrientation() -> AVCaptureVideoOrientation {
if let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) {
return captureOrientation(for: scene.interfaceOrientation)
}
return .landscapeRight
}
@MainActor
static var isPortraitContent: Bool {
let orientation = captureOrientation()
return orientation == .portrait || orientation == .portraitUpsideDown
}
private static func captureOrientation(for orientation: UIInterfaceOrientation) -> AVCaptureVideoOrientation {
switch orientation {
case .portrait: return .portrait
case .portraitUpsideDown: return .portraitUpsideDown
case .landscapeLeft: return .landscapeLeft
case .landscapeRight: return .landscapeRight
default: return .landscapeRight
}
}
}
@@ -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)
@@ -79,7 +79,12 @@ struct BroadcastScreen: View {
.onChange(of: scoreController.lastActionError) { message in
if let message { snackbarMessage = message }
}
.onChange(of: broadcastCoordinator.metrics.phase) { _ in updateOverlay() }
.onChange(of: broadcastCoordinator.metrics.phase) { phase in
updateOverlay()
if phase == .error, let message = broadcastCoordinator.metrics.lastError {
self.error = message
}
}
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK") { onFinished() }
} message: {
@@ -287,18 +292,19 @@ struct BroadcastScreen: View {
match = loadedMatch
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
wireCable()
await preloadLogos(for: loadedMatch)
loading = false
updateOverlay()
try? await Task.sleep(nanoseconds: 100_000_000)
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
let config = broadcastConfig(for: loaded, rtmpUrl: url)
if loaded.isPaused {
try await container.broadcastCoordinator.engine.preparePreview(config: config)
} else {
try await container.broadcastCoordinator.startBroadcast(config: config)
try await container.broadcastCoordinator.engine.prepareForBroadcast(config: config)
}
}
await preloadLogos(for: loadedMatch)
startPolling()
loading = false
updateOverlay()
} catch {
if let message = UserFacingError.message(for: error) {
self.error = message
@@ -58,13 +58,13 @@ struct LoginScreen: View {
_ = try await container.authRepository.login(email: email.trimmingCharacters(in: .whitespaces), password: password)
onLoggedIn()
} catch {
let message = error.localizedDescription
if message.contains("401") {
if let apiError = error as? APIError, case .unauthorized = apiError {
self.error = "Email o password non corretti"
} else if message.localizedCaseInsensitiveContains("timeout") {
} else if let message = UserFacingError.message(for: error),
message.localizedCaseInsensitiveContains("timeout") {
self.error = "Server non raggiungibile. Verifica la connessione."
} else {
self.error = message
self.error = UserFacingError.message(for: error) ?? "Accesso non riuscito"
}
}
loading = false
@@ -1,3 +1,5 @@
import HaishinKit
import RTMPHaishinKit
import SwiftUI
import UIKit
@@ -77,6 +79,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
AppOrientation.lockPortrait()
Task {
await SessionBuilderFactory.shared.register(RTMPSessionFactory())
}
return true
}