Stabilizza diretta iOS e aggiunge test simulatore pre-release.

Corregge lifecycle RTMP/preview tra partite, reset tabellone, orientamento
con debounce, condivisione su iPad e script test_ios_simulator per CI locale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-20 12:02:14 +02:00
co-authored by Cursor
parent 0f69ebcccb
commit 731b43ea88
25 changed files with 1101 additions and 562 deletions
@@ -39,6 +39,11 @@ final class LiveBroadcastEngine: ObservableObject {
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 lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)?
private static var rtmpFactoryRegistered = false
@@ -60,14 +65,38 @@ final class LiveBroadcastEngine: ObservableObject {
/// Anteprima locale: output del mixer (come HaishinKit PublishViewModel), non dello stream RTMP.
func bindPreview(to view: MTHKView) async {
previewView = view
guard pipelineConfigured, !previewAttached else { return }
await mixer.addOutput(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
publishPending = false
@@ -88,12 +117,15 @@ final class LiveBroadcastEngine: ObservableObject {
try await prepareForBroadcast(config: config)
}
func pauseBroadcast() async {
func pauseBroadcast(suppressDisconnectError: Bool = false) async {
self.suppressDisconnectError = suppressDisconnectError
defer { self.suppressDisconnectError = false }
broadcastGeneration += 1
publishPending = false
publishTask?.cancel()
publishTask = nil
await teardownRTMP(keepPreview: true)
setPhase(.paused)
await teardownRTMP(keepPreview: true)
}
func resumeBroadcast(config: BroadcastConfig) async throws {
@@ -101,27 +133,35 @@ final class LiveBroadcastEngine: ObservableObject {
publishPending = true
if !pipelineConfigured {
try await configurePipeline(config)
} else {
await attachPreviewIfNeeded()
}
setPhase(.connecting)
await startPendingPublish()
}
func stopBroadcast() async {
func stopBroadcast(suppressDisconnectError: Bool = false) async {
self.suppressDisconnectError = suppressDisconnectError
defer { self.suppressDisconnectError = false }
broadcastGeneration += 1
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
previewView = nil
overlayRenderer.detach()
try? await mixer.stopRunning()
pipelineConfigured = false
setPhase(.idle)
}
func release() async {
@@ -133,7 +173,10 @@ final class LiveBroadcastEngine: ObservableObject {
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw APIError.http(403, "Permessi camera e microfono richiesti")
}
if pipelineConfigured { return }
if pipelineConfigured {
await attachPreviewIfNeeded()
return
}
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
@@ -149,9 +192,11 @@ final class LiveBroadcastEngine: ObservableObject {
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.attachVideo(camera, track: 0) { videoUnit in
videoUnit.isVideoMirrored = false
}
}
await applyVideoOrientation()
await applyVideoOrientationIfNeeded(force: true)
startOrientationMonitoring()
try await mixer.startRunning()
@@ -162,11 +207,7 @@ final class LiveBroadcastEngine: ObservableObject {
isPortrait: isPortrait
)
pipelineConfigured = true
if let previewView, !previewAttached {
await mixer.addOutput(previewView)
previewAttached = true
}
await attachPreviewIfNeeded()
}
private func configureScreenSize(width: Int, height: Int) async {
@@ -186,40 +227,48 @@ final class LiveBroadcastEngine: ObservableObject {
private func waitForPreviewThenPublish() async {
guard publishPending, let config else { return }
let generation = broadcastGeneration
publishInFlight = true
defer { publishInFlight = false }
for _ in 0..<80 {
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 else {
publishPending = false
setPhase(.error, error: "Anteprima camera non pronta")
guard previewAttached, generation == broadcastGeneration else {
if generation == broadcastGeneration {
publishPending = false
setPhase(.error, error: "Anteprima camera non pronta")
}
return
}
setPhase(.connecting)
try? await Task.sleep(nanoseconds: 350_000_000)
guard publishPending else { return }
guard publishPending, generation == broadcastGeneration else { return }
do {
try await publish(config: config)
publishPending = false
try await publish(config: config, generation: generation)
if generation == broadcastGeneration {
publishPending = false
}
} catch {
guard generation == broadcastGeneration else { return }
publishPending = false
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
setPhase(.error, error: message)
}
}
private func publish(config: BroadcastConfig) async throws {
private func publish(config: BroadcastConfig, generation: Int) async throws {
guard let url = URL(string: config.rtmpUrl) else {
throw APIError.http(400, "URL RTMP non valido")
}
await Self.ensureRTMPFactoryRegistered()
await applyVideoOrientation()
await applyVideoOrientationIfNeeded(force: true)
await teardownRTMP(keepPreview: true)
let session = try await SessionBuilderFactory.shared.make(url)
@@ -246,14 +295,21 @@ final class LiveBroadcastEngine: ObservableObject {
})
await mixer.addOutput(stream)
rtmpSession = session
observeReadyState(session)
observeReadyState(session, generation: generation)
try await session.connect { [weak self] in
Task { @MainActor in
guard let self, self.phase == .live else { return }
guard let self,
!self.shouldSuppressDisconnectError,
self.broadcastGeneration == generation,
self.phase == .live else { return }
self.setPhase(.error, error: "Connessione RTMP interrotta")
}
}
guard generation == broadcastGeneration else {
await teardownRTMP(keepPreview: true)
return
}
setPhase(.live)
}
@@ -277,14 +333,16 @@ final class LiveBroadcastEngine: ObservableObject {
try await stream.setAudioSettings(audioSettings)
}
private func observeReadyState(_ session: any Session) {
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:
guard !shouldSuppressDisconnectError else { return }
setPhase(.error, error: "Connessione RTMP interrotta")
default:
break
@@ -307,15 +365,49 @@ final class LiveBroadcastEngine: ObservableObject {
}
}
private func applyVideoOrientation() async {
private var shouldSuppressDisconnectError: Bool {
suppressDisconnectError || Date() < orientationTransitionUntil
}
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()
@@ -325,12 +417,14 @@ final class LiveBroadcastEngine: ObservableObject {
queue: .main
) { [weak self] _ in
Task { @MainActor in
await self?.applyVideoOrientation()
self?.scheduleOrientationUpdate()
}
}
}
private func stopOrientationMonitoring() {
orientationDebounceTask?.cancel()
orientationDebounceTask = nil
if let orientationObserver {
NotificationCenter.default.removeObserver(orientationObserver)
self.orientationObserver = nil