Aggiunge app iOS nativa con parità multi-sport e test unitari.

Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-14 21:37:32 +02:00
co-authored by Cursor
parent b798e0ea06
commit 585332e32e
101 changed files with 7910 additions and 5 deletions
@@ -0,0 +1,176 @@
import AVFoundation
import Foundation
import HaishinKit
import RTMPHaishinKit
import UIKit
struct RTMPPublishTarget: Sendable {
let connectURL: String
let streamName: String
}
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)
}
}
@MainActor
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 pipelineConfigured = false
private var isPortrait = false
init() {
stream = RTMPStream(connection: connection)
}
func setMetricsListener(_ block: ((BroadcastMetrics) -> Void)?) {
metricsListener = block
emitMetrics()
}
func updateOverlay(_ state: OverlayState) {
overlayRenderer.update(state: state, isPortrait: isPortrait)
}
func bindPreview(to view: MTHKView) async {
await stream.addOutput(view)
}
func preparePreview(config: BroadcastConfig) async throws {
self.config = config
try await configurePipeline(config)
setPhase(.preview)
}
func startBroadcast(config: BroadcastConfig) async throws {
self.config = config
publishTask?.cancel()
publishTask = nil
try await configurePipeline(config)
setPhase(.connecting)
try await Task.sleep(nanoseconds: 350_000_000)
try await publish(config: config)
}
func pauseBroadcast() async {
try? await stream.close()
setPhase(.paused)
}
func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config
if !pipelineConfigured {
try await configurePipeline(config)
}
setPhase(.connecting)
try await publish(config: config)
}
func stopBroadcast() async {
publishTask?.cancel()
publishTask = nil
overlayRenderer.detach()
try? await stream.close()
try? await connection.close()
try? await mixer.stopRunning()
pipelineConfigured = false
setPhase(.idle)
reconnectAttempts = 0
}
func release() async {
await stopBroadcast()
}
private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw APIError.http(403, "Permessi camera e microfono richiesti")
}
if pipelineConfigured { return }
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
try audioSession.setActive(true)
isPortrait = config.portrait
var videoSettings = await mixer.videoMixerSettings
videoSettings.mode = .offscreen
try await mixer.setVideoMixerSettings(videoSettings)
await configureScreenSize(width: config.width, height: config.height)
if let microphone = AVCaptureDevice.default(for: .audio) {
try await mixer.attachAudio(microphone, track: 0)
}
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)
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)
pipelineConfigured = true
}
private func configureScreenSize(width: Int, height: Int) async {
await Task { @ScreenActor in
mixer.screen.size = CGSize(width: width, height: height)
mixer.screen.backgroundColor = UIColor.clear.cgColor
}.value
}
private func publish(config: BroadcastConfig) async throws {
guard let target = RTMPUrlParser.parse(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
setPhase(.live)
}
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,
lastError: error
)
metricsListener?(metrics)
}
private func emitMetrics() {
metricsListener?(metrics)
}
}