Files
MatchLiveTv/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift
T
eminuxandCursor 061b7fad8b Merge branch 'feature/adaptive-bitrate' into collaudo
Integra bitrate adattivo e telemetria, con il mute audio come icona nella toolbar della regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:16:26 +02:00

583 lines
22 KiB
Swift

import AVFoundation
import Foundation
import HaishinKit
import RTMPHaishinKit
import UIKit
import VideoToolbox
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable {
let controller: AdaptiveBitrateController
let onUpdate: @Sendable (Int) -> Void
var mamimumVideoBitRate: Int { controller.ceiling }
let mamimumAudioBitRate: Int = 0
init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
self.controller = controller
self.onUpdate = onUpdate
}
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
let next: Int?
switch event {
case .status(let report):
let measuredBits = report.currentBytesOutPerSecond * 8
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
case .publishInsufficientBWOccured(let report):
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
case .reset:
next = controller.resetToCeiling()
@unknown default:
next = nil
}
guard let bitrate = next else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
}
@MainActor
final class LiveBroadcastEngine: ObservableObject {
@Published private(set) var metrics = BroadcastMetrics()
let mixer = MediaMixer()
private let overlayRenderer = OverlayRenderer()
private var config: BroadcastConfig?
private var phase: BroadcastPhase = .idle
private var metricsListener: ((BroadcastMetrics) -> Void)?
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 broadcastGeneration = 0
private var suppressDisconnectError = false
private var orientationTransitionUntil: Date = .distantPast
private var reconnectAttempts = 0
private var reconnectTask: Task<Void, Never>?
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)?
private var audioMuted = false
private let adaptiveBitrate = AdaptiveBitrateController()
private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async {
guard !rtmpFactoryRegistered else { return }
await SessionBuilderFactory.shared.register(RTMPSessionFactory())
rtmpFactoryRegistered = true
}
func setMetricsListener(_ block: ((BroadcastMetrics) -> Void)?) {
metricsListener = block
emitMetrics()
}
func updateOverlay(_ state: OverlayState) {
overlayRenderer.update(state: state, isPortrait: isPortrait)
}
/// Anteprima locale: output del mixer (come HaishinKit PublishViewModel), non dello stream RTMP.
func bindPreview(to view: MTHKView) async {
previewView = view
await attachPreviewIfNeeded()
}
private func attachPreviewIfNeeded() async {
guard let previewView, pipelineConfigured, !previewAttached else { return }
await mixer.addOutput(previewView)
previewAttached = true
if publishPending {
await startPendingPublish()
}
}
/// Attende che SwiftUI monti la surface di anteprima (MTHKView).
func waitForPreviewSurface(maxAttempts: Int = 100) async -> Bool {
for _ in 0..<maxAttempts {
if previewView != nil { return true }
try? await Task.sleep(nanoseconds: 50_000_000)
}
return previewView != nil
}
/// Attende che l'anteprima sia collegata al mixer (dopo `configurePipeline`).
func waitForPreviewReady(maxAttempts: Int = 100) async -> Bool {
for _ in 0..<maxAttempts {
if Task.isCancelled { return previewAttached }
await attachPreviewIfNeeded()
if previewAttached { return true }
try? await Task.sleep(nanoseconds: 50_000_000)
}
return previewAttached
}
func preparePreview(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = false
try await configurePipeline(config)
setPhase(.preview)
}
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
func prepareForBroadcast(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = true
try await configurePipeline(config)
setPhase(.connecting)
await startPendingPublish()
}
func startBroadcast(config: BroadcastConfig) async throws {
try await prepareForBroadcast(config: config)
}
func pauseBroadcast(suppressDisconnectError: Bool = false) async {
self.suppressDisconnectError = suppressDisconnectError
defer { self.suppressDisconnectError = false }
broadcastGeneration += 1
reconnectTask?.cancel()
reconnectTask = nil
reconnectAttempts = 0
publishPending = false
publishTask?.cancel()
publishTask = nil
setPhase(.paused)
await teardownRTMP(keepPreview: true)
}
func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
reconnectAttempts = 0
reconnectTask?.cancel()
reconnectTask = nil
publishPending = true
if !pipelineConfigured {
try await configurePipeline(config)
} else {
await attachPreviewIfNeeded()
}
setPhase(.connecting)
await startPendingPublish()
}
func stopBroadcast(suppressDisconnectError: Bool = false) async {
self.suppressDisconnectError = suppressDisconnectError
defer { self.suppressDisconnectError = false }
broadcastGeneration += 1
reconnectTask?.cancel()
reconnectTask = nil
reconnectAttempts = 0
publishTask?.cancel()
publishTask = nil
publishPending = false
publishInFlight = false
stopOrientationMonitoring()
orientationDebounceTask?.cancel()
orientationDebounceTask = nil
lastAppliedCaptureOrientation = nil
orientationTransitionUntil = .distantPast
setPhase(.idle)
await teardownRTMP(keepPreview: false)
if let previewView, previewAttached {
await mixer.removeOutput(previewView)
}
previewAttached = false
overlayRenderer.detach()
try? await mixer.stopRunning()
pipelineConfigured = false
audioMuted = false
}
func release() async {
await stopBroadcast()
}
/// Silenzia il microfono senza togliere la traccia AAC (YouTube/MediaMTX richiedono audio continuo).
func setAudioMuted(_ muted: Bool) async {
audioMuted = muted
await applyAudioMute()
metrics.audioMuted = muted
emitMetrics()
}
/// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP.
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return }
adaptiveBitrate.setCeiling(videoBitrate)
let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
guard current.videoBitrate != videoBitrate || current.fps != fps else {
await applyEncoderBitrate(applied)
return
}
current.videoBitrate = videoBitrate
current.fps = fps
config = current
if pipelineConfigured, let stream = await rtmpSession?.stream {
try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied)
}
}
func setMinQualityFloor(_ floorBitrate: Int, ceiling: Int? = nil) async {
if var current = config {
if let ceiling { current.videoBitrate = ceiling }
current.minVideoBitrate = floorBitrate
config = current
}
await applyEncoderBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
private func applyEncoderBitrate(_ bitrate: Int) async {
guard pipelineConfigured, let stream = await rtmpSession?.stream else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw APIError.http(403, L10n.t("broadcast.error.permissions.camera.mic"))
}
if pipelineConfigured {
await attachPreviewIfNeeded()
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)
}
// AAC 48 kHz mono — allineato ad Android, slate MediaMTX e YoutubeRelay (-c:a copy).
try await mixer.setAudioMixerSettings(
AudioMixerSettings(
sampleRate: BroadcastConfig.audioSampleRateHz,
channels: BroadcastConfig.audioChannels,
isMuted: audioMuted,
tracks: [0: AudioMixerTrackSettings(isMuted: audioMuted, downmix: true, channelMap: [0])]
)
)
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
try await mixer.attachVideo(camera, track: 0) { videoUnit in
videoUnit.isVideoMirrored = false
}
}
await applyVideoOrientationIfNeeded(force: true)
startOrientationMonitoring()
try await mixer.startRunning()
overlayRenderer.attach(
mixer: mixer,
width: config.width,
height: config.height,
isPortrait: isPortrait
)
pipelineConfigured = true
await attachPreviewIfNeeded()
}
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 startPendingPublish() async {
guard publishPending, !publishInFlight else { return }
publishTask?.cancel()
publishTask = Task {
await waitForPreviewThenPublish()
}
}
private func waitForPreviewThenPublish() async {
guard publishPending, let config else { return }
let generation = broadcastGeneration
publishInFlight = true
defer { publishInFlight = false }
for _ in 0..<120 {
if Task.isCancelled { return }
if generation != broadcastGeneration { return }
await attachPreviewIfNeeded()
if previewAttached { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
guard previewAttached, generation == broadcastGeneration else {
if generation == broadcastGeneration {
publishPending = false
setPhase(.error, error: L10n.t("broadcast.error.camera.preview.not.ready"))
}
return
}
setPhase(.connecting)
try? await Task.sleep(nanoseconds: 350_000_000)
guard publishPending, generation == broadcastGeneration else { return }
do {
try await publish(config: config, generation: generation)
if generation == broadcastGeneration {
publishPending = false
reconnectAttempts = 0
}
} catch {
guard generation == broadcastGeneration else { return }
publishPending = false
if phase == .reconnecting || reconnectAttempts > 0 {
scheduleReconnect()
return
}
let message = UserFacingError.message(for: error) ?? L10n.t("broadcast.error.rtmp.connect.failed")
setPhase(.error, error: message)
}
}
private func publish(config: BroadcastConfig, generation: Int) async throws {
guard let url = URL(string: config.rtmpUrl) else {
throw APIError.http(400, L10n.t("broadcast.error.rtmp.url.invalid"))
}
await Self.ensureRTMPFactoryRegistered()
await applyVideoOrientationIfNeeded(force: true)
await teardownRTMP(keepPreview: true)
let session = try await SessionBuilderFactory.shared.make(url)
.setMode(.publish)
.build()
guard let session else {
throw APIError.http(500, L10n.t("broadcast.error.rtmp.session.create"))
}
let stream = await session.stream
try await applyCodecSettings(to: stream, config: config)
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [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,
audioMuted: self.audioMuted
)
self.metricsListener?(self.metrics)
}
})
await mixer.addOutput(stream)
rtmpSession = session
observeReadyState(session, generation: generation)
try await session.connect { [weak self] in
Task { @MainActor in
guard let self,
self.broadcastGeneration == generation,
self.phase == .live || self.phase == .reconnecting else { return }
self.scheduleReconnect()
}
}
guard generation == broadcastGeneration else {
await teardownRTMP(keepPreview: true)
return
}
reconnectAttempts = 0
setPhase(.live)
}
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws {
var videoSettings = await stream.videoSettings
videoSettings.videoSize = .init(width: config.width, height: config.height)
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate
videoSettings.maxKeyFrameIntervalDuration = 2
videoSettings.profileLevel = config.height > 720
? (kVTProfileLevel_H264_Baseline_4_1 as String)
: (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: true,
channelMap: [0],
sampleRate: BroadcastConfig.audioSampleRateHz,
format: .aac
)
try await stream.setAudioSettings(audioSettings)
}
private func observeReadyState(_ session: any Session, generation: Int) {
readyStateTask?.cancel()
readyStateTask = Task {
for await state in await session.readyState {
guard generation == broadcastGeneration else { return }
switch state {
case .open:
setPhase(.live)
case .closed where phase == .live || phase == .reconnecting:
scheduleReconnect()
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 var shouldSuppressDisconnectError: Bool {
suppressDisconnectError || Date() < orientationTransitionUntil
}
/// Riconnessione automatica RTMP (come Android): evita falsi errori su iPad.
private func scheduleReconnect() {
guard !shouldSuppressDisconnectError else { return }
guard phase == .live || phase == .reconnecting else { return }
guard let config else { return }
reconnectAttempts += 1
if reconnectAttempts > config.maxReconnectAttempts {
reconnectTask = nil
setPhase(.error, error: L10n.t("error.rtmp.interrupted"))
return
}
setPhase(.reconnecting)
reconnectTask?.cancel()
let generation = broadcastGeneration
let delayMs = config.reconnectDelayMs
reconnectTask = Task {
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
guard !Task.isCancelled, generation == broadcastGeneration else { return }
guard phase == .reconnecting else { return }
publishPending = true
publishInFlight = false
await startPendingPublish()
}
}
private func applyVideoOrientationIfNeeded(force: Bool = false) async {
let orientation = BroadcastVideoOrientation.captureOrientation()
let landscapeLocked = AppOrientation.mode == .landscape
if !force,
!BroadcastOrientationPolicy.shouldApplyMixerOrientation(
new: orientation,
previous: lastAppliedCaptureOrientation,
landscapeLocked: landscapeLocked
) {
return
}
guard BroadcastOrientationPolicy.isApplicableForBroadcast(orientation, landscapeLocked: landscapeLocked) else {
return
}
if phase == .live || phase == .connecting {
orientationTransitionUntil = Date().addingTimeInterval(BroadcastOrientationPolicy.suppressDisconnectSeconds)
}
lastAppliedCaptureOrientation = orientation
await mixer.setVideoOrientation(orientation)
try? await mixer.configuration(video: 0) { videoUnit in
videoUnit.isVideoMirrored = false
}
let portrait = BroadcastVideoOrientation.isPortraitContent
guard portrait != isPortrait else { return }
isPortrait = portrait
overlayRenderer.refreshOrientation(isPortrait: portrait)
}
private func scheduleOrientationUpdate() {
orientationDebounceTask?.cancel()
orientationDebounceTask = Task {
try? await Task.sleep(nanoseconds: BroadcastOrientationPolicy.debounceNanoseconds)
guard !Task.isCancelled else { return }
await applyVideoOrientationIfNeeded()
}
}
private func startOrientationMonitoring() {
stopOrientationMonitoring()
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
orientationObserver = NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.scheduleOrientationUpdate()
}
}
}
private func stopOrientationMonitoring() {
orientationDebounceTask?.cancel()
orientationDebounceTask = nil
if let orientationObserver {
NotificationCenter.default.removeObserver(orientationObserver)
self.orientationObserver = nil
}
UIDevice.current.endGeneratingDeviceOrientationNotifications()
}
private func applyAudioMute() async {
guard pipelineConfigured else { return }
var settings = await mixer.audioMixerSettings
settings.isMuted = audioMuted
var track = settings.tracks[0] ?? AudioMixerTrackSettings(downmix: true, channelMap: [0])
track.isMuted = audioMuted
settings.tracks[0] = track
try? await mixer.setAudioMixerSettings(settings)
}
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,
lastError: error,
audioMuted: audioMuted
)
metricsListener?(metrics)
}
private func emitMetrics() {
metricsListener?(metrics)
}
}