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:
co-authored by
Cursor
parent
b798e0ea06
commit
585332e32e
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user