Integra bitrate adattivo e telemetria, con il mute audio come icona nella toolbar della regia. Co-authored-by: Cursor <cursoragent@cursor.com>
583 lines
22 KiB
Swift
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)
|
|
}
|
|
}
|